Compare commits

..

1 Commits

Author SHA1 Message Date
undergroundwires
f9a54c7e68 Fix Colima builds failing 2024-05-20 17:32:21 +02:00
93 changed files with 1914 additions and 5150 deletions

View File

@@ -1,15 +0,0 @@
inputs:
name:
required: true
path:
required: true
runs:
using: composite
steps:
-
name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.name }}
path: ${{ inputs.path }}

View File

@@ -86,9 +86,21 @@ jobs:
name: Install Docker on macOS name: Install Docker on macOS
if: contains(matrix.os, 'macos') # macOS runner is missing Docker if: contains(matrix.os, 'macos') # macOS runner is missing Docker
run: |- run: |-
# Verify Intel-based macOS
arch=$(uname -m)
case "$arch" in
i386|x86_64)
echo "Supported architecture: $arch"
;;
*)
>&2 echo 'The macOS is not running on a supported Intel architecture. Virtualization is not supported.'
exit 1
;;
esac
# Install Docker # Install Docker
brew install docker brew install docker
# Docker on macOS misses daemon due to licensing, so install colima as runtime # Docker on macOS does not include the Docker daemon due to licensing issues.
# Install Colima to use as the Docker runtime.
brew install colima brew install colima
# Start the daemon # Start the daemon
colima start colima start

View File

@@ -10,8 +10,8 @@ jobs:
strategy: strategy:
matrix: matrix:
os: os:
- macos-latest # Apple silicon (ARM64) - macos-latest # Latest Apple silicon (ARM64)
- macos-13 # Intel-based (x86-64) - macos-12 # Latest Intel-based (x86-64)
- ubuntu-latest - ubuntu-latest
- windows-latest - windows-latest
fail-fast: false # Allows to see results from other combinations fail-fast: false # Allows to see results from other combinations
@@ -70,7 +70,7 @@ jobs:
- -
name: Upload screenshot name: Upload screenshot
if: always() # Run even if previous step fails if: always() # Run even if previous step fails
uses: ./.github/actions/upload-artifact uses: actions/upload-artifact@v3
with: with:
name: screenshot-${{ matrix.os }} name: screenshot-${{ matrix.os }}
path: screenshot.png path: screenshot.png

View File

@@ -51,14 +51,14 @@ jobs:
- -
name: Upload screenshots name: Upload screenshots
if: failure() # Run only if previous steps fail because screenshots will be generated only if E2E test failed if: failure() # Run only if previous steps fail because screenshots will be generated only if E2E test failed
uses: ./.github/actions/upload-artifact uses: actions/upload-artifact@v3
with: with:
name: e2e-screenshots-${{ matrix.os }} name: e2e-screenshots-${{ matrix.os }}
path: ${{ steps.artifacts.outputs.SCREENSHOTS_DIR }} path: ${{ steps.artifacts.outputs.SCREENSHOTS_DIR }}
- -
name: Upload videos name: Upload videos
if: always() # Run even if previous steps fail because test run video is always captured if: always() # Run even if previous steps fail because test run video is always captured
uses: ./.github/actions/upload-artifact uses: actions/upload-artifact@v3
with: with:
name: e2e-videos-${{ matrix.os }} name: e2e-videos-${{ matrix.os }}
path: ${{ steps.artifacts.outputs.VIDEOS_DIR }} path: ${{ steps.artifacts.outputs.VIDEOS_DIR }}

View File

@@ -1,27 +1,5 @@
# Changelog # Changelog
## 0.13.4 (2024-05-27)
* Add specific empty function name compiler error | [870120b](https://github.com/undergroundwires/privacy.sexy/commit/870120bc13909a3681e0f0a2351806849476342f)
* ci/cd: fix recent Docker build failures on macOS | [a1922c5](https://github.com/undergroundwires/privacy.sexy/commit/a1922c50c12b3b7806e9e681ace842194a178bda)
* win: standardize registry edit + delete on revert | [cec0b4b](https://github.com/undergroundwires/privacy.sexy/commit/cec0b4b4f63c3563a0e7923ce6324a38d71a3955)
* Fix e2e test failing on Windows | [4a7efa2](https://github.com/undergroundwires/privacy.sexy/commit/4a7efa27c8df73ef9b7960afed29f216b066cba2)
* Add support for macOS universal binary #348, #362 | [d25c4e8](https://github.com/undergroundwires/privacy.sexy/commit/d25c4e8c812b8d012010ba38070a2931dcd28908)
* Migrate to GitHub issue forms | [9ab3ff7](https://github.com/undergroundwires/privacy.sexy/commit/9ab3ff75b0a69ac2ba27dd02e82db9b5bd76ea0f)
* ci/cd: fix quality checks not running on all OSes | [2390530](https://github.com/undergroundwires/privacy.sexy/commit/2390530d929fb92c266558c52376569a0ecb90c1)
* Bump Vue to latest and fix universal selector CSS | [aae5434](https://github.com/undergroundwires/privacy.sexy/commit/aae54344511ec51d17ad0420a92cb5a064e0e7bb)
* Centralize and optimize `ResizeObserver` usage | [2923621](https://github.com/undergroundwires/privacy.sexy/commit/292362135db0519ec1050bab80ed373aad115731)
* win: improve app access disabling and docs #138 | [ff3d5c4](https://github.com/undergroundwires/privacy.sexy/commit/ff3d5c48419f663379f5aba8936636c22f2c5de8)
* win: document and discourage RSA key script #363 | [f347fde](https://github.com/undergroundwires/privacy.sexy/commit/f347fde0c85f8b51b0060fdea0a2724b042aaeed)
* win: improve printing removal /w Print Queue #279 | [150e067](https://github.com/undergroundwires/privacy.sexy/commit/150e0670392bb62348c20ec644a4ed8a6bbffe74)
* win: discourage blocking app access #121 #339 #350 | [7794846](https://github.com/undergroundwires/privacy.sexy/commit/77948461856e6837ddfbcbbef72a1bf9fc706b4e)
* Improve context for errors thrown by compiler | [4212c7b](https://github.com/undergroundwires/privacy.sexy/commit/4212c7b9e0b1500378a1e4e88efc2d59f39f3d29)
* win: document disabling firewall #115 #152 #364 | [12b1f18](https://github.com/undergroundwires/privacy.sexy/commit/12b1f183f7ce966d6ce090d98aeea7ec491f8c7c)
* win: add script to disable Recall feature | [ce4cfdd](https://github.com/undergroundwires/privacy.sexy/commit/ce4cfdd169b7da0edc3da61143c988ed5f3c976e)
* win, mac, linux: fix typos and dead URLs #367 | [9e34e64](https://github.com/undergroundwires/privacy.sexy/commit/9e34e644493674ca709b64a47206763d5d4bd60c)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.13.3...0.13.4)
## 0.13.3 (2024-05-11) ## 0.13.3 (2024-05-11)
* win: organize and document network disablement | [2eed6f4](https://github.com/undergroundwires/privacy.sexy/commit/2eed6f4afb6cf85fdc1d6acb808f82405a35cafd) * win: organize and document network disablement | [2eed6f4](https://github.com/undergroundwires/privacy.sexy/commit/2eed6f4afb6cf85fdc1d6acb808f82405a35cafd)

View File

@@ -122,7 +122,7 @@
## Get started ## Get started
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy). - 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.4/privacy.sexy-Setup-0.13.4.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.4/privacy.sexy-0.13.4.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.4/privacy.sexy-0.13.4.AppImage). For more options, see [here](#additional-install-options). - 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.3/privacy.sexy-Setup-0.13.3.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.3/privacy.sexy-0.13.3.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.3/privacy.sexy-0.13.3.AppImage). For more options, see [here](#additional-install-options).
See also: See also:

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "privacy.sexy", "name": "privacy.sexy",
"version": "0.13.4", "version": "0.13.3",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "privacy.sexy", "name": "privacy.sexy",
"version": "0.13.4", "version": "0.13.3",
"private": true, "private": true,
"slogan": "Privacy is sexy", "slogan": "Privacy is sexy",
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy.", "description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy.",

View File

@@ -1,4 +1,4 @@
import { isFunction, type ConstructorArguments } from '@/TypeHelpers'; import { isFunction } from '@/TypeHelpers';
/* /*
Provides a unified and resilient way to extend errors across platforms. Provides a unified and resilient way to extend errors across platforms.
@@ -12,8 +12,8 @@ import { isFunction, type ConstructorArguments } from '@/TypeHelpers';
> https://web.archive.org/web/20230810014143/https://github.com/Microsoft/TypeScript-wiki/blob/main/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work > https://web.archive.org/web/20230810014143/https://github.com/Microsoft/TypeScript-wiki/blob/main/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
*/ */
export abstract class CustomError extends Error { export abstract class CustomError extends Error {
constructor(...args: ConstructorArguments<typeof Error>) { constructor(message?: string, options?: ErrorOptions) {
super(...args); super(message, options);
fixPrototype(this, new.target.prototype); fixPrototype(this, new.target.prototype);
ensureStackTrace(this); ensureStackTrace(this);

View File

@@ -3,12 +3,10 @@ import type {
} from '@/application/collections/'; } from '@/application/collections/';
import { Script } from '@/domain/Script'; import { Script } from '@/domain/Script';
import { Category } from '@/domain/Category'; import { Category } from '@/domain/Category';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import { NodeValidator } from '@/application/Parser/NodeValidation/NodeValidator';
import type { ICategory } from '@/domain/ICategory'; import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import { parseDocs, type DocsParser } from './DocumentationParser'; import { parseDocs } from './DocumentationParser';
import { parseScript, type ScriptParser } from './Script/ScriptParser'; import { parseScript } from './Script/ScriptParser';
import { createNodeDataValidator, type NodeDataValidator, type NodeDataValidatorFactory } from './NodeValidation/NodeDataValidator';
import { NodeDataType } from './NodeValidation/NodeDataType';
import type { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext'; import type { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext';
let categoryIdCounter = 0; let categoryIdCounter = 0;
@@ -16,108 +14,96 @@ let categoryIdCounter = 0;
export function parseCategory( export function parseCategory(
category: CategoryData, category: CategoryData,
context: ICategoryCollectionParseContext, context: ICategoryCollectionParseContext,
utilities: CategoryParserUtilities = DefaultCategoryParserUtilities, factory: CategoryFactoryType = CategoryFactory,
): Category { ): Category {
return parseCategoryRecursively({ return parseCategoryRecursively({
categoryData: category, categoryData: category,
context, context,
utilities, factory,
}); });
} }
interface CategoryParseContext { interface ICategoryParseContext {
readonly categoryData: CategoryData; readonly categoryData: CategoryData,
readonly context: ICategoryCollectionParseContext; readonly context: ICategoryCollectionParseContext,
readonly parentCategory?: CategoryData; readonly factory: CategoryFactoryType,
readonly utilities: CategoryParserUtilities; readonly parentCategory?: CategoryData,
} }
function parseCategoryRecursively( function parseCategoryRecursively(context: ICategoryParseContext): Category | never {
context: CategoryParseContext, ensureValidCategory(context.categoryData, context.parentCategory);
): Category | never { const children: ICategoryChildren = {
const validator = ensureValidCategory(context); subCategories: new Array<Category>(),
const children: CategoryChildren = { subScripts: new Array<Script>(),
subcategories: new Array<Category>(),
subscripts: new Array<Script>(),
}; };
for (const data of context.categoryData.children) { for (const data of context.categoryData.children) {
parseNode({ parseNode({
nodeData: data, nodeData: data,
children, children,
parent: context.categoryData, parent: context.categoryData,
utilities: context.utilities, factory: context.factory,
context: context.context, context: context.context,
}); });
} }
try { try {
return context.utilities.createCategory({ return context.factory(
id: categoryIdCounter++, /* id: */ categoryIdCounter++,
name: context.categoryData.category, /* name: */ context.categoryData.category,
docs: context.utilities.parseDocs(context.categoryData), /* docs: */ parseDocs(context.categoryData),
subcategories: children.subcategories, /* categories: */ children.subCategories,
scripts: children.subscripts, /* scripts: */ children.subScripts,
});
} catch (error) {
throw context.utilities.wrapError(
error,
validator.createContextualErrorMessage('Failed to parse category.'),
); );
} catch (err) {
return new NodeValidator({
type: NodeType.Category,
selfNode: context.categoryData,
parentNode: context.parentCategory,
}).throw(err.message);
} }
} }
function ensureValidCategory( function ensureValidCategory(category: CategoryData, parentCategory?: CategoryData) {
context: CategoryParseContext, new NodeValidator({
): NodeDataValidator { type: NodeType.Category,
const category = context.categoryData; selfNode: category,
const validator: NodeDataValidator = context.utilities.createValidator({ parentNode: parentCategory,
type: NodeDataType.Category, })
selfNode: context.categoryData, .assertDefined(category)
parentNode: context.parentCategory, .assertValidName(category.category)
}); .assert(
validator.assertDefined(category); () => category.children.length > 0,
validator.assertValidName(category.category);
validator.assert(
() => Boolean(category.children) && category.children.length > 0,
`"${category.category}" has no children.`, `"${category.category}" has no children.`,
); );
return validator;
} }
interface CategoryChildren { interface ICategoryChildren {
readonly subcategories: Category[]; subCategories: Category[];
readonly subscripts: Script[]; subScripts: Script[];
} }
interface NodeParseContext { interface INodeParseContext {
readonly nodeData: CategoryOrScriptData; readonly nodeData: CategoryOrScriptData;
readonly children: CategoryChildren; readonly children: ICategoryChildren;
readonly parent: CategoryData; readonly parent: CategoryData;
readonly factory: CategoryFactoryType;
readonly context: ICategoryCollectionParseContext; readonly context: ICategoryCollectionParseContext;
readonly utilities: CategoryParserUtilities;
} }
function parseNode(context: INodeParseContext) {
function parseNode(context: NodeParseContext) { const validator = new NodeValidator({ selfNode: context.nodeData, parentNode: context.parent });
const validator: NodeDataValidator = context.utilities.createValidator({
selfNode: context.nodeData,
parentNode: context.parent,
});
validator.assertDefined(context.nodeData); validator.assertDefined(context.nodeData);
validator.assert(
() => isCategory(context.nodeData) || isScript(context.nodeData),
'Node is neither a category or a script.',
);
if (isCategory(context.nodeData)) { if (isCategory(context.nodeData)) {
const subCategory = parseCategoryRecursively({ const subCategory = parseCategoryRecursively({
categoryData: context.nodeData, categoryData: context.nodeData,
context: context.context, context: context.context,
factory: context.factory,
parentCategory: context.parent, parentCategory: context.parent,
utilities: context.utilities,
}); });
context.children.subcategories.push(subCategory); context.children.subCategories.push(subCategory);
} else { // A script } else if (isScript(context.nodeData)) {
const script = context.utilities.parseScript(context.nodeData, context.context); const script = parseScript(context.nodeData, context.context);
context.children.subscripts.push(script); context.children.subScripts.push(script);
} else {
validator.throw('Node is neither a category or a script.');
} }
} }
@@ -137,35 +123,11 @@ function hasCall(data: unknown) {
return hasProperty(data, 'call'); return hasProperty(data, 'call');
} }
function hasProperty( function hasProperty(object: unknown, propertyName: string) {
object: unknown,
propertyName: string,
): object is NonNullable<object> {
if (typeof object !== 'object') {
return false;
}
if (object === null) { // `typeof object` is `null`
return false;
}
return Object.prototype.hasOwnProperty.call(object, propertyName); return Object.prototype.hasOwnProperty.call(object, propertyName);
} }
export type CategoryFactory = ( export type CategoryFactoryType = (
...parameters: ConstructorParameters<typeof Category> ...parameters: ConstructorParameters<typeof Category>) => Category;
) => ICategory;
interface CategoryParserUtilities { const CategoryFactory: CategoryFactoryType = (...parameters) => new Category(...parameters);
readonly createCategory: CategoryFactory;
readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: NodeDataValidatorFactory;
readonly parseScript: ScriptParser;
readonly parseDocs: DocsParser;
}
const DefaultCategoryParserUtilities: CategoryParserUtilities = {
createCategory: (...parameters) => new Category(...parameters),
wrapError: wrapErrorWithAdditionalContext,
createValidator: createNodeDataValidator,
parseScript,
parseDocs,
};

View File

@@ -1,42 +0,0 @@
import { CustomError } from '@/application/Common/CustomError';
export interface ErrorWithContextWrapper {
(
error: Error,
additionalContext: string,
): Error;
}
export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = (
error: Error,
additionalContext: string,
) => {
return (error instanceof ContextualError ? error : new ContextualError(error))
.withAdditionalContext(additionalContext);
};
/* AggregateError is similar but isn't well-serialized or displayed by browsers */
class ContextualError extends CustomError {
private readonly additionalContext = new Array<string>();
constructor(
public readonly innerError: Error,
) {
super();
}
public withAdditionalContext(additionalContext: string): this {
this.additionalContext.push(additionalContext);
return this;
}
public get message(): string { // toString() is not used when Chromium logs it on console
return [
'\n',
this.innerError.message,
'\n',
'Additional context:',
...this.additionalContext.map((context, index) => `${index + 1}: ${context}`),
].join('\n');
}
}

View File

@@ -1,7 +1,7 @@
import type { DocumentableData, DocumentationData } from '@/application/collections/'; import type { DocumentableData, DocumentationData } from '@/application/collections/';
import { isString, isArray } from '@/TypeHelpers'; import { isString, isArray } from '@/TypeHelpers';
export const parseDocs: DocsParser = (documentable) => { export function parseDocs(documentable: DocumentableData): readonly string[] {
const { docs } = documentable; const { docs } = documentable;
if (!docs) { if (!docs) {
return []; return [];
@@ -9,12 +9,6 @@ export const parseDocs: DocsParser = (documentable) => {
let result = new DocumentationContainer(); let result = new DocumentationContainer();
result = addDocs(docs, result); result = addDocs(docs, result);
return result.getAll(); return result.getAll();
};
export interface DocsParser {
(
documentable: DocumentableData,
): readonly string[];
} }
function addDocs( function addDocs(

View File

@@ -0,0 +1,34 @@
import { CustomError } from '@/application/Common/CustomError';
import { NodeType } from './NodeType';
import type { NodeData } from './NodeData';
export class NodeDataError extends CustomError {
constructor(message: string, public readonly context: INodeDataErrorContext) {
super(createMessage(message, context));
}
}
export interface INodeDataErrorContext {
readonly type?: NodeType;
readonly selfNode: NodeData;
readonly parentNode?: NodeData;
}
function createMessage(errorMessage: string, context: INodeDataErrorContext) {
let message = '';
if (context.type !== undefined) {
message += `${NodeType[context.type]}: `;
}
message += errorMessage;
message += `\n${dump(context)}`;
return message;
}
function dump(context: INodeDataErrorContext): string {
const printJson = (obj: unknown) => JSON.stringify(obj, undefined, 2);
let output = `Self: ${printJson(context.selfNode)}`;
if (context.parentNode) {
output += `\nParent: ${printJson(context.parentNode)}`;
}
return output;
}

View File

@@ -1,25 +0,0 @@
import type { CategoryData, ScriptData } from '@/application/collections/';
import { NodeDataType } from './NodeDataType';
import type { NodeData } from './NodeData';
export type NodeDataErrorContext = {
readonly parentNode?: CategoryData;
} & (CategoryNodeErrorContext | ScriptNodeErrorContext | UnknownNodeErrorContext);
export type CategoryNodeErrorContext = {
readonly type: NodeDataType.Category;
readonly selfNode: CategoryData;
readonly parentNode?: CategoryData;
};
export type ScriptNodeErrorContext = {
readonly type: NodeDataType.Script;
readonly selfNode: ScriptData;
readonly parentNode?: CategoryData;
};
export type UnknownNodeErrorContext = {
readonly type?: undefined;
readonly selfNode: NodeData;
readonly parentNode?: CategoryData;
};

View File

@@ -1,35 +0,0 @@
import { NodeDataType } from './NodeDataType';
import type { NodeDataErrorContext } from './NodeDataErrorContext';
import type { NodeData } from './NodeData';
export interface NodeContextErrorMessageCreator {
(
errorMessage: string,
context: NodeDataErrorContext,
): string;
}
export const createNodeContextErrorMessage: NodeContextErrorMessageCreator = (
errorMessage,
context,
) => {
let message = '';
if (context.type !== undefined) {
message += `${NodeDataType[context.type]}: `;
}
message += errorMessage;
message += `\n${getErrorContextDetails(context)}`;
return message;
};
function getErrorContextDetails(context: NodeDataErrorContext): string {
let output = `Self: ${printNodeDataAsJson(context.selfNode)}`;
if (context.parentNode) {
output += `\nParent: ${printNodeDataAsJson(context.parentNode)}`;
}
return output;
}
function printNodeDataAsJson(node: NodeData): string {
return JSON.stringify(node, undefined, 2);
}

View File

@@ -1,4 +0,0 @@
export enum NodeDataType {
Script,
Category,
}

View File

@@ -1,69 +0,0 @@
import { isString } from '@/TypeHelpers';
import { type NodeDataErrorContext } from './NodeDataErrorContext';
import { createNodeContextErrorMessage, type NodeContextErrorMessageCreator } from './NodeDataErrorContextMessage';
import type { NodeData } from './NodeData';
export interface NodeDataValidatorFactory {
(context: NodeDataErrorContext): NodeDataValidator;
}
export interface NodeDataValidator {
assertValidName(nameValue: string): void;
assertDefined(
node: NodeData | undefined,
): asserts node is NonNullable<NodeData> & void;
assert(
validationPredicate: () => boolean,
errorMessage: string,
): asserts validationPredicate is (() => true);
createContextualErrorMessage(errorMessage: string): string;
}
export const createNodeDataValidator
: NodeDataValidatorFactory = (context) => new ContextualNodeDataValidator(context);
export class ContextualNodeDataValidator implements NodeDataValidator {
constructor(
private readonly context: NodeDataErrorContext,
private readonly createErrorMessage
: NodeContextErrorMessageCreator = createNodeContextErrorMessage,
) {
}
public assertValidName(nameValue: string): void {
this.assert(() => Boolean(nameValue), 'missing name');
this.assert(
() => isString(nameValue),
`Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`,
);
}
public assertDefined(
node: NodeData,
): asserts node is NonNullable<NodeData> {
this.assert(
() => node !== undefined && node !== null && Object.keys(node).length > 0,
'missing node data',
);
}
public assert(
validationPredicate: () => boolean,
errorMessage: string,
): asserts validationPredicate is (() => true) {
if (!validationPredicate()) {
this.throw(errorMessage);
}
}
public createContextualErrorMessage(errorMessage: string): string {
return this.createErrorMessage(errorMessage, this.context);
}
private throw(errorMessage: string): never {
throw new Error(
this.createContextualErrorMessage(errorMessage),
);
}
}

View File

@@ -0,0 +1,4 @@
export enum NodeType {
Script,
Category,
}

View File

@@ -0,0 +1,39 @@
import { isString } from '@/TypeHelpers';
import { type INodeDataErrorContext, NodeDataError } from './NodeDataError';
import type { NodeData } from './NodeData';
export class NodeValidator {
constructor(private readonly context: INodeDataErrorContext) {
}
public assertValidName(nameValue: string) {
return this
.assert(
() => Boolean(nameValue),
'missing name',
)
.assert(
() => isString(nameValue),
`Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`,
);
}
public assertDefined(node: NodeData) {
return this.assert(
() => node !== undefined && node !== null && Object.keys(node).length > 0,
'missing node data',
);
}
public assert(validationPredicate: () => boolean, errorMessage: string) {
if (!validationPredicate()) {
this.throw(errorMessage);
}
return this;
}
public throw(errorMessage: string): never {
throw new NodeDataError(errorMessage, this.context);
}
}

View File

@@ -7,18 +7,15 @@ import type { IReadOnlyFunctionParameterCollection } from '../../Function/Parame
import type { IExpression } from './IExpression'; import type { IExpression } from './IExpression';
export type ExpressionEvaluator = (context: IExpressionEvaluationContext) => string; export type ExpressionEvaluator = (context: IExpressionEvaluationContext) => string;
export class Expression implements IExpression { export class Expression implements IExpression {
public readonly parameters: IReadOnlyFunctionParameterCollection; public readonly parameters: IReadOnlyFunctionParameterCollection;
public readonly position: ExpressionPosition; constructor(
public readonly position: ExpressionPosition,
public readonly evaluator: ExpressionEvaluator; public readonly evaluator: ExpressionEvaluator,
parameters?: IReadOnlyFunctionParameterCollection,
constructor(parameters: ExpressionInitParameters) { ) {
this.parameters = parameters.parameters ?? new FunctionParameterCollection(); this.parameters = parameters ?? new FunctionParameterCollection();
this.evaluator = parameters.evaluator;
this.position = parameters.position;
} }
public evaluate(context: IExpressionEvaluationContext): string { public evaluate(context: IExpressionEvaluationContext): string {
@@ -29,12 +26,6 @@ export class Expression implements IExpression {
} }
} }
export interface ExpressionInitParameters {
readonly position: ExpressionPosition,
readonly evaluator: ExpressionEvaluator,
readonly parameters?: IReadOnlyFunctionParameterCollection,
}
function validateThatAllRequiredParametersAreSatisfied( function validateThatAllRequiredParametersAreSatisfied(
parameters: IReadOnlyFunctionParameterCollection, parameters: IReadOnlyFunctionParameterCollection,
args: IReadOnlyFunctionCallArgumentCollection, args: IReadOnlyFunctionCallArgumentCollection,

View File

@@ -1,13 +1,8 @@
import { ExpressionPosition } from './ExpressionPosition'; import { ExpressionPosition } from './ExpressionPosition';
export interface ExpressionPositionFactory { export function createPositionFromRegexFullMatch(
(
match: RegExpMatchArray, match: RegExpMatchArray,
): ExpressionPosition ): ExpressionPosition {
}
export const createPositionFromRegexFullMatch
: ExpressionPositionFactory = (match) => {
const startPos = match.index; const startPos = match.index;
if (startPos === undefined) { if (startPos === undefined) {
throw new Error(`Regex match did not yield any results: ${JSON.stringify(match)}`); throw new Error(`Regex match did not yield any results: ${JSON.stringify(match)}`);
@@ -18,4 +13,4 @@ export const createPositionFromRegexFullMatch
} }
const endPos = startPos + fullMatch.length; const endPos = startPos + fullMatch.length;
return new ExpressionPosition(startPos, endPos); return new ExpressionPosition(startPos, endPos);
}; }

View File

@@ -3,10 +3,10 @@ import { WithParser } from '../SyntaxParsers/WithParser';
import type { IExpression } from '../Expression/IExpression'; import type { IExpression } from '../Expression/IExpression';
import type { IExpressionParser } from './IExpressionParser'; import type { IExpressionParser } from './IExpressionParser';
const Parsers: readonly IExpressionParser[] = [ const Parsers = [
new ParameterSubstitutionParser(), new ParameterSubstitutionParser(),
new WithParser(), new WithParser(),
] as const; ];
export class CompositeExpressionParser implements IExpressionParser { export class CompositeExpressionParser implements IExpressionParser {
public constructor(private readonly leafs: readonly IExpressionParser[] = Parsers) { public constructor(private readonly leafs: readonly IExpressionParser[] = Parsers) {

View File

@@ -1,127 +1,53 @@
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { Expression, type ExpressionEvaluator } from '../../Expression/Expression'; import { Expression, type ExpressionEvaluator } from '../../Expression/Expression';
import { createPositionFromRegexFullMatch, type ExpressionPositionFactory } from '../../Expression/ExpressionPositionFactory'; import { FunctionParameterCollection } from '../../../Function/Parameter/FunctionParameterCollection';
import { createFunctionParameterCollection, type FunctionParameterCollectionFactory } from '../../../Function/Parameter/FunctionParameterCollectionFactory'; import { createPositionFromRegexFullMatch } from '../../Expression/ExpressionPositionFactory';
import type { IExpressionParser } from '../IExpressionParser'; import type { IExpressionParser } from '../IExpressionParser';
import type { IExpression } from '../../Expression/IExpression'; import type { IExpression } from '../../Expression/IExpression';
import type { IFunctionParameter } from '../../../Function/Parameter/IFunctionParameter'; import type { IFunctionParameter } from '../../../Function/Parameter/IFunctionParameter';
import type { IFunctionParameterCollection, IReadOnlyFunctionParameterCollection } from '../../../Function/Parameter/IFunctionParameterCollection';
export interface RegexParserUtilities {
readonly wrapError: ErrorWithContextWrapper;
readonly createPosition: ExpressionPositionFactory;
readonly createExpression: ExpressionFactory;
readonly createParameterCollection: FunctionParameterCollectionFactory;
}
export abstract class RegexParser implements IExpressionParser { export abstract class RegexParser implements IExpressionParser {
protected abstract readonly regex: RegExp; protected abstract readonly regex: RegExp;
public constructor(
private readonly utilities: RegexParserUtilities = DefaultRegexParserUtilities,
) {
}
public findExpressions(code: string): IExpression[] { public findExpressions(code: string): IExpression[] {
return Array.from(this.findRegexExpressions(code)); return Array.from(this.findRegexExpressions(code));
} }
protected abstract buildExpression(match: RegExpMatchArray): PrimitiveExpression; protected abstract buildExpression(match: RegExpMatchArray): IPrimitiveExpression;
private* findRegexExpressions(code: string): Iterable<IExpression> { private* findRegexExpressions(code: string): Iterable<IExpression> {
if (!code) { if (!code) {
throw new Error( throw new Error('missing code');
this.buildErrorMessageWithContext({ errorMessage: 'missing code', code: 'EMPTY' }),
);
} }
const createErrorContext = (message: string): ErrorContext => ({ code, errorMessage: message }); const matches = code.matchAll(this.regex);
const matches = this.doOrRethrow(
() => code.matchAll(this.regex),
createErrorContext('Failed to match regex.'),
);
for (const match of matches) { for (const match of matches) {
const primitiveExpression = this.doOrRethrow( const primitiveExpression = this.buildExpression(match);
() => this.buildExpression(match), const position = this.doOrRethrow(() => createPositionFromRegexFullMatch(match), 'invalid script position', code);
createErrorContext('Failed to build expression.'), const parameters = createParameters(primitiveExpression);
); const expression = new Expression(position, primitiveExpression.evaluator, parameters);
const position = this.doOrRethrow(
() => this.utilities.createPosition(match),
createErrorContext('Failed to create position.'),
);
const parameters = this.doOrRethrow(
() => createParameters(
primitiveExpression,
this.utilities.createParameterCollection(),
),
createErrorContext('Failed to create parameters.'),
);
const expression = this.doOrRethrow(
() => this.utilities.createExpression({
position,
evaluator: primitiveExpression.evaluator,
parameters,
}),
createErrorContext('Failed to create expression.'),
);
yield expression; yield expression;
} }
} }
private doOrRethrow<T>( private doOrRethrow<T>(action: () => T, errorText: string, code: string): T {
action: () => T,
context: ErrorContext,
): T {
try { try {
return action(); return action();
} catch (error) { } catch (error) {
throw this.utilities.wrapError( throw new Error(`[${this.constructor.name}] ${errorText}: ${error.message}\nRegex: ${this.regex}\nCode: ${code}`);
error,
this.buildErrorMessageWithContext(context),
);
} }
} }
private buildErrorMessageWithContext(context: ErrorContext): string {
return [
context.errorMessage,
`Class name: ${this.constructor.name}`,
`Regex pattern used: ${this.regex}`,
`Code: ${context.code}`,
].join('\n');
}
}
interface ErrorContext {
readonly errorMessage: string,
readonly code: string,
} }
function createParameters( function createParameters(
expression: PrimitiveExpression, expression: IPrimitiveExpression,
parameterCollection: IFunctionParameterCollection, ): FunctionParameterCollection {
): IReadOnlyFunctionParameterCollection {
return (expression.parameters || []) return (expression.parameters || [])
.reduce((parameters, parameter) => { .reduce((parameters, parameter) => {
parameters.addParameter(parameter); parameters.addParameter(parameter);
return parameters; return parameters;
}, parameterCollection); }, new FunctionParameterCollection());
} }
export interface PrimitiveExpression { export interface IPrimitiveExpression {
readonly evaluator: ExpressionEvaluator; evaluator: ExpressionEvaluator;
readonly parameters?: readonly IFunctionParameter[]; parameters?: readonly IFunctionParameter[];
} }
export interface ExpressionFactory {
(
...args: ConstructorParameters<typeof Expression>
): IExpression;
}
const DefaultRegexParserUtilities: RegexParserUtilities = {
wrapError: wrapErrorWithAdditionalContext,
createPosition: createPositionFromRegexFullMatch,
createExpression: (...args) => new Expression(...args),
createParameterCollection: createFunctionParameterCollection,
};

View File

@@ -1,5 +1,5 @@
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter'; import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { RegexParser, type PrimitiveExpression } from '../Parser/Regex/RegexParser'; import { RegexParser, type IPrimitiveExpression } from '../Parser/Regex/RegexParser';
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder'; import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
export class ParameterSubstitutionParser extends RegexParser { export class ParameterSubstitutionParser extends RegexParser {
@@ -12,7 +12,7 @@ export class ParameterSubstitutionParser extends RegexParser {
.expectExpressionEnd() .expectExpressionEnd()
.buildRegExp(); .buildRegExp();
protected buildExpression(match: RegExpMatchArray): PrimitiveExpression { protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
const parameterName = match[1]; const parameterName = match[1];
const pipeline = match[2]; const pipeline = match[2];
return { return {

View File

@@ -8,7 +8,7 @@ export class FunctionCallArgument implements IFunctionCallArgument {
) { ) {
ensureValidParameterName(parameterName); ensureValidParameterName(parameterName);
if (!argumentValue) { if (!argumentValue) {
throw new Error(`Missing argument value for the parameter "${parameterName}".`); throw new Error(`missing argument value for "${parameterName}"`);
} }
} }
} }

View File

@@ -72,7 +72,7 @@ function throwIfUnexpectedParametersExist(
// eslint-disable-next-line prefer-template // eslint-disable-next-line prefer-template
`Function "${functionName}" has unexpected parameter(s) provided: ` `Function "${functionName}" has unexpected parameter(s) provided: `
+ `"${unexpectedParameters.join('", "')}"` + `"${unexpectedParameters.join('", "')}"`
+ '.\nExpected parameter(s): ' + '. Expected parameter(s): '
+ (expectedParameters.length ? `"${expectedParameters.join('", "')}".` : 'none'), + (expectedParameters.length ? `"${expectedParameters.join('", "')}"` : 'none'),
); );
} }

View File

@@ -6,14 +6,11 @@ import type { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/
import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall'; import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
import type { FunctionCallCompilationContext } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext'; import type { FunctionCallCompilationContext } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
import { ParsedFunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/ParsedFunctionCall'; import { ParsedFunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/ParsedFunctionCall';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import type { ArgumentCompiler } from './ArgumentCompiler'; import type { ArgumentCompiler } from './ArgumentCompiler';
export class NestedFunctionArgumentCompiler implements ArgumentCompiler { export class NestedFunctionArgumentCompiler implements ArgumentCompiler {
constructor( constructor(
private readonly expressionsCompiler: IExpressionsCompiler = new ExpressionsCompiler(), private readonly expressionsCompiler: IExpressionsCompiler = new ExpressionsCompiler(),
private readonly wrapError: ErrorWithContextWrapper
= wrapErrorWithAdditionalContext,
) { } ) { }
public createCompiledNestedCall( public createCompiledNestedCall(
@@ -25,26 +22,18 @@ export class NestedFunctionArgumentCompiler implements ArgumentCompiler {
nestedFunction, nestedFunction,
parentFunction.args, parentFunction.args,
context, context,
{ this.expressionsCompiler,
expressionsCompiler: this.expressionsCompiler,
wrapError: this.wrapError,
},
); );
const compiledCall = new ParsedFunctionCall(nestedFunction.functionName, compiledArgs); const compiledCall = new ParsedFunctionCall(nestedFunction.functionName, compiledArgs);
return compiledCall; return compiledCall;
} }
} }
interface ArgumentCompilationUtilities {
readonly expressionsCompiler: IExpressionsCompiler,
readonly wrapError: ErrorWithContextWrapper;
}
function compileNestedFunctionArguments( function compileNestedFunctionArguments(
nestedFunction: FunctionCall, nestedFunction: FunctionCall,
parentFunctionArgs: IReadOnlyFunctionCallArgumentCollection, parentFunctionArgs: IReadOnlyFunctionCallArgumentCollection,
context: FunctionCallCompilationContext, context: FunctionCallCompilationContext,
utilities: ArgumentCompilationUtilities, expressionsCompiler: IExpressionsCompiler,
): IReadOnlyFunctionCallArgumentCollection { ): IReadOnlyFunctionCallArgumentCollection {
const requiredParameterNames = context const requiredParameterNames = context
.allFunctions .allFunctions
@@ -58,7 +47,7 @@ function compileNestedFunctionArguments(
paramName, paramName,
nestedFunction, nestedFunction,
parentFunctionArgs, parentFunctionArgs,
utilities, expressionsCompiler,
), ),
})) }))
// Filter out arguments with absent values // Filter out arguments with absent values
@@ -100,13 +89,13 @@ function compileArgument(
parameterName: string, parameterName: string,
nestedFunction: FunctionCall, nestedFunction: FunctionCall,
parentFunctionArgs: IReadOnlyFunctionCallArgumentCollection, parentFunctionArgs: IReadOnlyFunctionCallArgumentCollection,
utilities: ArgumentCompilationUtilities, expressionsCompiler: IExpressionsCompiler,
): string { ): string {
try { try {
const { argumentValue: codeInArgument } = nestedFunction.args.getArgument(parameterName); const { argumentValue: codeInArgument } = nestedFunction.args.getArgument(parameterName);
return utilities.expressionsCompiler.compileExpressions(codeInArgument, parentFunctionArgs); return expressionsCompiler.compileExpressions(codeInArgument, parentFunctionArgs);
} catch (error) { } catch (err) {
throw utilities.wrapError(error, `Error when compiling argument for "${parameterName}"`); throw new AggregateError([err], `Error when compiling argument for "${parameterName}"`);
} }
} }

View File

@@ -1,21 +1,14 @@
import { import { type CallFunctionBody, FunctionBodyType, type ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
type CallFunctionBody, FunctionBodyType,
type ISharedFunction,
} from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall'; import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
import type { FunctionCallCompilationContext } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext'; import type { FunctionCallCompilationContext } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode'; import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { NestedFunctionArgumentCompiler } from './Argument/NestedFunctionArgumentCompiler'; import { 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';
export class NestedFunctionCallCompiler implements SingleCallCompilerStrategy { export class NestedFunctionCallCompiler implements SingleCallCompilerStrategy {
public constructor( public constructor(
private readonly argumentCompiler: ArgumentCompiler private readonly argumentCompiler: ArgumentCompiler = new NestedFunctionArgumentCompiler(),
= new NestedFunctionArgumentCompiler(),
private readonly wrapError: ErrorWithContextWrapper
= wrapErrorWithAdditionalContext,
) { ) {
} }
@@ -36,11 +29,8 @@ export class NestedFunctionCallCompiler implements SingleCallCompilerStrategy {
const compiledNestedCall = context.singleCallCompiler const compiledNestedCall = context.singleCallCompiler
.compileSingleCall(compiledParentCall, context); .compileSingleCall(compiledParentCall, context);
return compiledNestedCall; return compiledNestedCall;
} catch (error) { } catch (err) {
throw this.wrapError( throw new AggregateError([err], `Error with call to "${nestedCall.functionName}" function from "${callToFunction.functionName}" function`);
error,
`Failed to call '${nestedCall.functionName}' (callee function) from '${callToFunction.functionName}' (caller function).`,
);
} }
}).flat(); }).flat();
} }

View File

@@ -1,12 +0,0 @@
import { FunctionParameterCollection } from './FunctionParameterCollection';
import type { IFunctionParameterCollection } from './IFunctionParameterCollection';
export interface FunctionParameterCollectionFactory {
(
...args: ConstructorParameters<typeof FunctionParameterCollection>
): IFunctionParameterCollection;
}
export const createFunctionParameterCollection: FunctionParameterCollectionFactory = (...args) => {
return new FunctionParameterCollection(...args);
};

View File

@@ -15,7 +15,7 @@ export class SharedFunctionCollection implements ISharedFunctionCollection {
if (!name) { throw Error('missing function name'); } if (!name) { throw Error('missing function name'); }
const func = this.functionsByName.get(name); const func = this.functionsByName.get(name);
if (!func) { if (!func) {
throw new Error(`Called function is not defined: "${name}"`); throw new Error(`called function is not defined "${name}"`);
} }
return func; return func;
} }

View File

@@ -1,6 +1,5 @@
import type { import type {
FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData, FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData, CallInstruction,
CallInstruction, ParameterDefinitionData,
} from '@/application/collections/'; } from '@/application/collections/';
import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator'; import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator';
@@ -8,30 +7,20 @@ import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmp
import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines'; import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines';
import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers'; import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/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';
import { FunctionParameterCollection } from './Parameter/FunctionParameterCollection';
import { parseFunctionCalls } from './Call/FunctionCallParser'; import { parseFunctionCalls } from './Call/FunctionCallParser';
import { createFunctionParameterCollection, type FunctionParameterCollectionFactory } from './Parameter/FunctionParameterCollectionFactory';
import type { ISharedFunctionCollection } from './ISharedFunctionCollection'; import type { ISharedFunctionCollection } from './ISharedFunctionCollection';
import type { ISharedFunctionsParser } from './ISharedFunctionsParser'; import type { ISharedFunctionsParser } from './ISharedFunctionsParser';
import type { IReadOnlyFunctionParameterCollection } from './Parameter/IFunctionParameterCollection'; import type { IReadOnlyFunctionParameterCollection } from './Parameter/IFunctionParameterCollection';
import type { ISharedFunction } from './ISharedFunction'; import type { ISharedFunction } from './ISharedFunction';
const DefaultSharedFunctionsParsingUtilities: SharedFunctionsParsingUtilities = {
wrapError: wrapErrorWithAdditionalContext,
createParameter: (...args) => new FunctionParameter(...args),
codeValidator: CodeValidator.instance,
createParameterCollection: createFunctionParameterCollection,
};
export class SharedFunctionsParser implements ISharedFunctionsParser { export class SharedFunctionsParser implements ISharedFunctionsParser {
public static readonly instance: ISharedFunctionsParser = new SharedFunctionsParser(); public static readonly instance: ISharedFunctionsParser = new SharedFunctionsParser();
constructor( constructor(private readonly codeValidator: ICodeValidator = CodeValidator.instance) { }
private readonly utilities = DefaultSharedFunctionsParsingUtilities,
) { }
public parseFunctions( public parseFunctions(
functions: readonly FunctionData[], functions: readonly FunctionData[],
@@ -43,7 +32,7 @@ export class SharedFunctionsParser implements ISharedFunctionsParser {
} }
ensureValidFunctions(functions); ensureValidFunctions(functions);
return functions return functions
.map((func) => parseFunction(func, syntax, this.utilities)) .map((func) => parseFunction(func, syntax, this.codeValidator))
.reduce((acc, func) => { .reduce((acc, func) => {
acc.addFunction(func); acc.addFunction(func);
return acc; return acc;
@@ -51,26 +40,15 @@ export class SharedFunctionsParser implements ISharedFunctionsParser {
} }
} }
interface SharedFunctionsParsingUtilities {
readonly wrapError: ErrorWithContextWrapper;
readonly createParameter: FunctionParameterFactory;
readonly codeValidator: ICodeValidator;
readonly createParameterCollection: FunctionParameterCollectionFactory;
}
export type FunctionParameterFactory = (
...args: ConstructorParameters<typeof FunctionParameter>
) => FunctionParameter;
function parseFunction( function parseFunction(
data: FunctionData, data: FunctionData,
syntax: ILanguageSyntax, syntax: ILanguageSyntax,
utilities: SharedFunctionsParsingUtilities, validator: ICodeValidator,
): ISharedFunction { ): ISharedFunction {
const { name } = data; const { name } = data;
const parameters = parseParameters(data, utilities); const parameters = parseParameters(data);
if (hasCode(data)) { if (hasCode(data)) {
validateCode(data, syntax, utilities.codeValidator); validateCode(data, syntax, validator);
return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode); return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode);
} }
// Has call // Has call
@@ -93,38 +71,22 @@ function validateCode(
); );
} }
function parseParameters( function parseParameters(data: FunctionData): IReadOnlyFunctionParameterCollection {
data: FunctionData,
utilities: SharedFunctionsParsingUtilities,
): IReadOnlyFunctionParameterCollection {
return (data.parameters || []) return (data.parameters || [])
.map((parameter) => createFunctionParameter( .map((parameter) => {
data.name, try {
parameter, return new FunctionParameter(
utilities, parameter.name,
)) parameter.optional || false,
);
} catch (err) {
throw new Error(`"${data.name}": ${err.message}`);
}
})
.reduce((parameters, parameter) => { .reduce((parameters, parameter) => {
parameters.addParameter(parameter); parameters.addParameter(parameter);
return parameters; return parameters;
}, utilities.createParameterCollection()); }, new FunctionParameterCollection());
}
function createFunctionParameter(
functionName: string,
parameterData: ParameterDefinitionData,
utilities: SharedFunctionsParsingUtilities,
): FunctionParameter {
try {
return utilities.createParameter(
parameterData.name,
parameterData.optional || false,
);
} catch (err) {
throw utilities.wrapError(
err,
`Failed to create parameter: ${parameterData.name} for function "${functionName}"`,
);
}
} }
function hasCode(data: FunctionData): data is CodeFunctionData { function hasCode(data: FunctionData): data is CodeFunctionData {

View File

@@ -1,11 +1,10 @@
import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/'; import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/';
import type { IScriptCode } from '@/domain/IScriptCode'; import type { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode';
import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator'; import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator';
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { createScriptCode, type ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
import { SharedFunctionsParser } from './Function/SharedFunctionsParser'; import { SharedFunctionsParser } from './Function/SharedFunctionsParser';
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler'; import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
import { parseFunctionCalls } from './Function/Call/FunctionCallParser'; import { parseFunctionCalls } from './Function/Call/FunctionCallParser';
@@ -24,8 +23,6 @@ export class ScriptCompiler implements IScriptCompiler {
sharedFunctionsParser: ISharedFunctionsParser = SharedFunctionsParser.instance, sharedFunctionsParser: ISharedFunctionsParser = SharedFunctionsParser.instance,
private readonly callCompiler: FunctionCallCompiler = FunctionCallSequenceCompiler.instance, private readonly callCompiler: FunctionCallCompiler = FunctionCallSequenceCompiler.instance,
private readonly codeValidator: ICodeValidator = CodeValidator.instance, private readonly codeValidator: ICodeValidator = CodeValidator.instance,
private readonly wrapError: ErrorWithContextWrapper = wrapErrorWithAdditionalContext,
private readonly scriptCodeFactory: ScriptCodeFactory = createScriptCode,
) { ) {
this.functions = sharedFunctionsParser.parseFunctions(functions, syntax); this.functions = sharedFunctionsParser.parseFunctions(functions, syntax);
} }
@@ -42,12 +39,12 @@ export class ScriptCompiler implements IScriptCompiler {
const calls = parseFunctionCalls(script.call); const calls = parseFunctionCalls(script.call);
const compiledCode = this.callCompiler.compileFunctionCalls(calls, this.functions); const compiledCode = this.callCompiler.compileFunctionCalls(calls, this.functions);
validateCompiledCode(compiledCode, this.codeValidator); validateCompiledCode(compiledCode, this.codeValidator);
return this.scriptCodeFactory( return new ScriptCode(
compiledCode.code, compiledCode.code,
compiledCode.revertCode, compiledCode.revertCode,
); );
} catch (error) { } catch (error) {
throw this.wrapError(error, `Failed to compile script: ${script.name}`); throw Error(`Script "${script.name}" ${error.message}`);
} }
} }
} }

View File

@@ -4,52 +4,37 @@ import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syn
import { Script } from '@/domain/Script'; import { Script } from '@/domain/Script';
import { RecommendationLevel } from '@/domain/RecommendationLevel'; import { RecommendationLevel } from '@/domain/RecommendationLevel';
import type { IScriptCode } from '@/domain/IScriptCode'; import type { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode';
import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import { parseDocs } from '../DocumentationParser';
import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
import { createScriptCode } from '@/domain/ScriptCodeFactory';
import type { IScript } from '@/domain/IScript';
import { parseDocs, type DocsParser } from '../DocumentationParser';
import { createEnumParser, type IEnumParser } from '../../Common/Enum'; import { createEnumParser, type IEnumParser } from '../../Common/Enum';
import { NodeDataType } from '../NodeValidation/NodeDataType'; import { NodeType } from '../NodeValidation/NodeType';
import { createNodeDataValidator, type NodeDataValidator, type NodeDataValidatorFactory } from '../NodeValidation/NodeDataValidator'; import { NodeValidator } from '../NodeValidation/NodeValidator';
import { CodeValidator } from './Validation/CodeValidator'; import { CodeValidator } from './Validation/CodeValidator';
import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines'; import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines';
import type { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext'; import type { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext';
export interface ScriptParser { export function parseScript(
(
data: ScriptData, data: ScriptData,
context: ICategoryCollectionParseContext, context: ICategoryCollectionParseContext,
utilities?: ScriptParserUtilities, levelParser = createEnumParser(RecommendationLevel),
): IScript; scriptFactory: ScriptFactoryType = ScriptFactory,
} codeValidator: ICodeValidator = CodeValidator.instance,
): Script {
export const parseScript: ScriptParser = ( const validator = new NodeValidator({ type: NodeType.Script, selfNode: data });
data,
context,
utilities = DefaultScriptParserUtilities,
) => {
const validator = utilities.createValidator({
type: NodeDataType.Script,
selfNode: data,
});
validateScript(data, validator); validateScript(data, validator);
try { try {
const script = utilities.createScript({ const script = scriptFactory(
name: data.name, /* name: */ data.name,
code: parseCode(data, context, utilities.codeValidator, utilities.createCode), /* code: */ parseCode(data, context, codeValidator),
docs: utilities.parseDocs(data), /* docs: */ parseDocs(data),
level: parseLevel(data.recommend, utilities.levelParser), /* level: */ parseLevel(data.recommend, levelParser),
});
return script;
} catch (error) {
throw utilities.wrapError(
error,
validator.createContextualErrorMessage('Failed to parse script.'),
); );
return script;
} catch (err) {
return validator.throw(err.message);
} }
}; }
function parseLevel( function parseLevel(
level: string | undefined, level: string | undefined,
@@ -65,19 +50,18 @@ function parseCode(
script: ScriptData, script: ScriptData,
context: ICategoryCollectionParseContext, context: ICategoryCollectionParseContext,
codeValidator: ICodeValidator, codeValidator: ICodeValidator,
createCode: ScriptCodeFactory,
): IScriptCode { ): IScriptCode {
if (context.compiler.canCompile(script)) { if (context.compiler.canCompile(script)) {
return context.compiler.compile(script); return context.compiler.compile(script);
} }
const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled
const code = createCode(codeScript.code, codeScript.revertCode); const code = new ScriptCode(codeScript.code, codeScript.revertCode);
validateHardcodedCodeWithoutCalls(code, codeValidator, context.syntax); validateHardcodedCodeWithoutCalls(code, codeValidator, context.syntax);
return code; return code;
} }
function validateHardcodedCodeWithoutCalls( function validateHardcodedCodeWithoutCalls(
scriptCode: IScriptCode, scriptCode: ScriptCode,
validator: ICodeValidator, validator: ICodeValidator,
syntax: ILanguageSyntax, syntax: ILanguageSyntax,
) { ) {
@@ -93,48 +77,25 @@ function validateHardcodedCodeWithoutCalls(
function validateScript( function validateScript(
script: ScriptData, script: ScriptData,
validator: NodeDataValidator, validator: NodeValidator,
): asserts script is NonNullable<ScriptData> { ): asserts script is NonNullable<ScriptData> {
validator.assertDefined(script); validator
validator.assertValidName(script.name); .assertDefined(script)
validator.assert( .assertValidName(script.name)
.assert(
() => Boolean((script as CodeScriptData).code || (script as CallScriptData).call), () => Boolean((script as CodeScriptData).code || (script as CallScriptData).call),
'Neither "call" or "code" is defined.', 'Neither "call" or "code" is defined.',
); )
validator.assert( .assert(
() => !((script as CodeScriptData).code && (script as CallScriptData).call), () => !((script as CodeScriptData).code && (script as CallScriptData).call),
'Both "call" and "code" are defined.', 'Both "call" and "code" are defined.',
); )
validator.assert( .assert(
() => !((script as CodeScriptData).revertCode && (script as CallScriptData).call), () => !((script as CodeScriptData).revertCode && (script as CallScriptData).call),
'Both "call" and "revertCode" are defined.', 'Both "call" and "revertCode" are defined.',
); );
} }
interface ScriptParserUtilities { export type ScriptFactoryType = (...parameters: ConstructorParameters<typeof Script>) => Script;
readonly levelParser: IEnumParser<RecommendationLevel>;
readonly createScript: ScriptFactory;
readonly codeValidator: ICodeValidator;
readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: NodeDataValidatorFactory;
readonly createCode: ScriptCodeFactory;
readonly parseDocs: DocsParser;
}
export type ScriptFactory = ( const ScriptFactory: ScriptFactoryType = (...parameters) => new Script(...parameters);
...parameters: ConstructorParameters<typeof Script>
) => IScript;
const createScript: ScriptFactory = (...parameters) => {
return new Script(...parameters);
};
const DefaultScriptParserUtilities: ScriptParserUtilities = {
levelParser: createEnumParser(RecommendationLevel),
createScript,
codeValidator: CodeValidator.instance,
wrapError: wrapErrorWithAdditionalContext,
createValidator: createNodeDataValidator,
createCode: createScriptCode,
parseDocs,
};

View File

@@ -5,7 +5,6 @@ import { ExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expres
import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import { FunctionCallArgumentCollection } from '@/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgumentCollection'; import { FunctionCallArgumentCollection } from '@/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgumentCollection';
import { FunctionCallArgument } from '@/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument'; import { FunctionCallArgument } from '@/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument';
import type { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser';
import type { ICodeSubstituter } from './ICodeSubstituter'; import type { ICodeSubstituter } from './ICodeSubstituter';
export class CodeSubstituter implements ICodeSubstituter { export class CodeSubstituter implements ICodeSubstituter {
@@ -30,9 +29,7 @@ export class CodeSubstituter implements ICodeSubstituter {
} }
function createSubstituteCompiler(): IExpressionsCompiler { function createSubstituteCompiler(): IExpressionsCompiler {
const parsers: readonly IExpressionParser[] = [ const parsers = [new ParameterSubstitutionParser()];
new ParameterSubstitutionParser(),
] as const;
const parser = new CompositeExpressionParser(parsers); const parser = new CompositeExpressionParser(parsers);
const expressionCompiler = new ExpressionsCompiler(parser); const expressionCompiler = new ExpressionsCompiler(parser);
return expressionCompiler; return expressionCompiler;

View File

@@ -2072,8 +2072,8 @@ actions:
[1]: https://web.archive.org/web/20221029165307/https://packages.fedoraproject.org/pkgs/zeitgeist/zeitgeist/index.html "zeitgeist - Fedora Packages | packages.fedoraproject.org" [1]: https://web.archive.org/web/20221029165307/https://packages.fedoraproject.org/pkgs/zeitgeist/zeitgeist/index.html "zeitgeist - Fedora Packages | packages.fedoraproject.org"
[2]: https://web.archive.org/web/20221029165603/https://archlinux.org/packages/extra/x86_64/zeitgeist/ "Arch Linux - zeitgeist 1.0.4-1 (x86_64) | archlinux.org" [2]: https://web.archive.org/web/20221029165603/https://archlinux.org/packages/extra/x86_64/zeitgeist/ "Arch Linux - zeitgeist 1.0.4-1 (x86_64) | archlinux.org"
[3]: https://web.archive.org/web/20221029165609/https://packages.debian.org/search?keywords=zeitgeist-core "Debian -- Package Search Results -- zeitgeist-core | packages.debian.org" [3]: https://web.archive.org/web/20221029165609/https://packages.debian.org/search?keywords=zeitgeist-core "Debian -- Package Search Results -- zeitgeist-core | packages.debian.org"
[4]: https://web.archive.org/web/20221029165714/https://releases.ubuntu.com/xenial/ubuntu-16.04.6-desktop-i386.manifest "List of software packages shipped with Ubuntu 16.04.6 | releases.ubuntu.com" [4]: https://web.archive.org/web/20221029165714/https://releases.ubuntu.com/xenial/ubuntu-16.04.6-desktop-i386.manifest "List of sofware packags shipped with Ubuntu 16.04.6 | releases.ubuntu.com"
[5]: https://web.archive.org/web/20221029165726/https://releases.ubuntu.com/18.04/ubuntu-18.04.6-desktop-amd64.manifest "List of software packages shipped with Ubuntu 18.04.6 | releases.ubuntu.com" [5]: https://web.archive.org/web/20221029165726/https://releases.ubuntu.com/18.04/ubuntu-18.04.6-desktop-amd64.manifest "List of sofware packags shipped with Ubuntu 18.04.6 | releases.ubuntu.com"
[6]: https://web.archive.org/web/20221029165821/https://bugs.archlinux.org/task/52326 "FS#52326 : [midori-gtk2] Please remove the zeitgeist dependency! | archlinux.org" [6]: https://web.archive.org/web/20221029165821/https://bugs.archlinux.org/task/52326 "FS#52326 : [midori-gtk2] Please remove the zeitgeist dependency! | archlinux.org"
[7]: https://web.archive.org/web/20221029165914/https://forum.artixlinux.org/index.php/topic,1432.0.html "Remove Unmaintained Zeitgeist (Spyware/Telemetry) from Default MATE installation | artixlinux.org" [7]: https://web.archive.org/web/20221029165914/https://forum.artixlinux.org/index.php/topic,1432.0.html "Remove Unmaintained Zeitgeist (Spyware/Telemetry) from Default MATE installation | artixlinux.org"
[8]: https://web.archive.org/web/20221029165902/https://askubuntu.com/questions/45548/disabling-zeitgeist/57487 "Disabling Zeitgeist - Ask Ubuntu | askubuntu.com" [8]: https://web.archive.org/web/20221029165902/https://askubuntu.com/questions/45548/disabling-zeitgeist/57487 "Disabling Zeitgeist - Ask Ubuntu | askubuntu.com"

View File

@@ -1408,7 +1408,7 @@ actions:
name: Disable Gatekeeper name: Disable Gatekeeper
docs: docs:
# References for spctl --master-disable # References for spctl --master-disable
- https://web.archive.org/web/20240523173608/https://www.manpagez.com/man/8/spctl/ - https://www.manpagez.com/man/8/spctl/
# References for /var/db/SystemPolicy-prefs.plist # References for /var/db/SystemPolicy-prefs.plist
- https://krypted.com/mac-security/manage-gatekeeper-from-the-command-line-in-mountain-lion/ - https://krypted.com/mac-security/manage-gatekeeper-from-the-command-line-in-mountain-lion/
- https://community.jamf.com/t5/jamf-pro/users-can-t-change-password-greyed-out/m-p/54228 - https://community.jamf.com/t5/jamf-pro/users-can-t-change-password-greyed-out/m-p/54228

File diff suppressed because it is too large Load Diff

View File

@@ -5,21 +5,15 @@ import type { IScript } from './IScript';
export class Category extends BaseEntity<number> implements ICategory { export class Category extends BaseEntity<number> implements ICategory {
private allSubScripts?: ReadonlyArray<IScript> = undefined; private allSubScripts?: ReadonlyArray<IScript> = undefined;
public readonly name: string; constructor(
id: number,
public readonly docs: ReadonlyArray<string>; public readonly name: string,
public readonly docs: ReadonlyArray<string>,
public readonly subCategories: ReadonlyArray<ICategory>; public readonly subCategories: ReadonlyArray<ICategory>,
public readonly scripts: ReadonlyArray<IScript>,
public readonly scripts: ReadonlyArray<IScript>; ) {
super(id);
constructor(parameters: CategoryInitParameters) { validateCategory(this);
super(parameters.id);
validateParameters(parameters);
this.name = parameters.name;
this.docs = parameters.docs;
this.subCategories = parameters.subcategories;
this.scripts = parameters.scripts;
} }
public includes(script: IScript): boolean { public includes(script: IScript): boolean {
@@ -34,14 +28,6 @@ export class Category extends BaseEntity<number> implements ICategory {
} }
} }
export interface CategoryInitParameters {
readonly id: number;
readonly name: string;
readonly docs: ReadonlyArray<string>;
readonly subcategories: ReadonlyArray<ICategory>;
readonly scripts: ReadonlyArray<IScript>;
}
function parseScriptsRecursively(category: ICategory): ReadonlyArray<IScript> { function parseScriptsRecursively(category: ICategory): ReadonlyArray<IScript> {
return [ return [
...category.scripts, ...category.scripts,
@@ -49,11 +35,11 @@ function parseScriptsRecursively(category: ICategory): ReadonlyArray<IScript> {
]; ];
} }
function validateParameters(parameters: CategoryInitParameters) { function validateCategory(category: ICategory) {
if (!parameters.name) { if (!category.name) {
throw new Error('missing name'); throw new Error('missing name');
} }
if (parameters.subcategories.length === 0 && parameters.scripts.length === 0) { if (category.subCategories.length === 0 && category.scripts.length === 0) {
throw new Error('A category must have at least one sub-category or script'); throw new Error('A category must have at least one sub-category or script');
} }
} }

View File

@@ -4,21 +4,14 @@ import type { IScript } from './IScript';
import type { IScriptCode } from './IScriptCode'; import type { IScriptCode } from './IScriptCode';
export class Script extends BaseEntity<string> implements IScript { export class Script extends BaseEntity<string> implements IScript {
public readonly name: string; constructor(
public readonly name: string,
public readonly code: IScriptCode; public readonly code: IScriptCode,
public readonly docs: ReadonlyArray<string>,
public readonly docs: ReadonlyArray<string>; public readonly level?: RecommendationLevel,
) {
public readonly level?: RecommendationLevel; super(name);
validateLevel(level);
constructor(parameters: ScriptInitParameters) {
super(parameters.name);
this.name = parameters.name;
this.code = parameters.code;
this.docs = parameters.docs;
this.level = parameters.level;
validateLevel(parameters.level);
} }
public canRevert(): boolean { public canRevert(): boolean {
@@ -26,13 +19,6 @@ export class Script extends BaseEntity<string> implements IScript {
} }
} }
export interface ScriptInitParameters {
readonly name: string;
readonly code: IScriptCode;
readonly docs: ReadonlyArray<string>;
readonly level?: RecommendationLevel;
}
function validateLevel(level?: RecommendationLevel) { function validateLevel(level?: RecommendationLevel) {
if (level !== undefined && !(level in RecommendationLevel)) { if (level !== undefined && !(level in RecommendationLevel)) {
throw new Error(`invalid level: ${level}`); throw new Error(`invalid level: ${level}`);

View File

@@ -1,10 +0,0 @@
import { ScriptCode } from './ScriptCode';
import type { IScriptCode } from './IScriptCode';
export interface ScriptCodeFactory {
(
...args: ConstructorParameters<typeof ScriptCode>
): IScriptCode;
}
export const createScriptCode: ScriptCodeFactory = (...args) => new ScriptCode(...args);

View File

@@ -1,91 +0,0 @@
<template>
<div>
<CardExpansionArrow />
<div class="card__expander">
<div class="card__expander__close-button">
<FlatButton
icon="xmark"
@click="collapse()"
/>
</div>
<div class="card__expander__content">
<ScriptsTree
:category-id="categoryId"
:has-top-padding="false"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import {
defineComponent,
} from 'vue';
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
import CardExpansionArrow from './CardExpansionArrow.vue';
export default defineComponent({
components: {
ScriptsTree,
FlatButton,
CardExpansionArrow,
},
props: {
categoryId: {
type: Number,
required: true,
},
},
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
onCollapse: () => true,
/* eslint-enable @typescript-eslint/no-unused-vars */
},
setup(_, { emit }) {
function collapse() {
emit('onCollapse');
}
return {
collapse,
};
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
@use "./card-gap" as *;
.card__expander {
position: relative;
background-color: $color-primary-darker;
color: $color-on-primary;
margin-top: $spacing-absolute-xx-large;
display: flex;
align-items: center;
flex-direction: column;
.card__expander__content {
display: flex;
justify-content: center;
word-break: break-word;
max-width: 100%; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
width: 100%; // Expands the container to fill available horizontal space, enabling alignment of child items.
}
.card__expander__close-button {
font-size: $font-size-absolute-large;
align-self: flex-end;
margin-right: $spacing-absolute-small;
@include clickable;
color: $color-primary-light;
@include hover-or-touch {
color: $color-primary;
}
}
}
</style>

View File

@@ -27,7 +27,6 @@
:data-category="categoryId" :data-category="categoryId"
:category-id="categoryId" :category-id="categoryId"
:active-category-id="activeCategoryId" :active-category-id="activeCategoryId"
:card-layout="cardLayout"
@card-expansion-changed="onSelected(categoryId, $event)" @card-expansion-changed="onSelected(categoryId, $event)"
/> />
</div> </div>
@@ -47,7 +46,6 @@ import { injectKey } from '@/presentation/injectionSymbols';
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue'; import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
import { hasDirective } from './NonCollapsingDirective'; import { hasDirective } from './NonCollapsingDirective';
import CardListItem from './CardListItem.vue'; import CardListItem from './CardListItem.vue';
import { useCardLayout } from './UseCardLayout';
export default defineComponent({ export default defineComponent({
components: { components: {
@@ -63,14 +61,8 @@ export default defineComponent({
const categoryIds = computed<readonly number[]>( const categoryIds = computed<readonly number[]>(
() => currentState.value.collection.actions.map((category) => category.id), () => currentState.value.collection.actions.map((category) => category.id),
); );
const activeCategoryId = ref<number | undefined>(undefined); const activeCategoryId = ref<number | undefined>(undefined);
const cardLayout = useCardLayout({
containerWidth: computed(() => width.value ?? 0),
totalCards: computed(() => categoryIds.value.length),
});
function onSelected(categoryId: number, isExpanded: boolean) { function onSelected(categoryId: number, isExpanded: boolean) {
activeCategoryId.value = isExpanded ? categoryId : undefined; activeCategoryId.value = isExpanded ? categoryId : undefined;
} }
@@ -109,7 +101,6 @@ export default defineComponent({
width, width,
categoryIds, categoryIds,
activeCategoryId, activeCategoryId,
cardLayout,
onSelected, onSelected,
}; };
}, },

View File

@@ -29,12 +29,26 @@
/> />
</div> </div>
<CardExpandTransition> <CardExpandTransition>
<CardExpansionPanel <div v-show="isExpanded">
v-show="isExpanded" <CardExpansionArrow />
:category-id="categoryId" <div
@on-collapse="collapse" class="card__expander"
@click.stop @click.stop
>
<div class="card__expander__close-button">
<FlatButton
icon="xmark"
@click="collapse()"
/> />
</div>
<div class="card__expander__content">
<ScriptsTree
:category-id="categoryId"
:has-top-padding="false"
/>
</div>
</div>
</div>
</CardExpandTransition> </CardExpandTransition>
</div> </div>
</template> </template>
@@ -42,32 +56,30 @@
<script lang="ts"> <script lang="ts">
import { import {
defineComponent, computed, shallowRef, defineComponent, computed, shallowRef,
type PropType,
} from 'vue'; } from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue'; import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
import { sleep } from '@/infrastructure/Threading/AsyncSleep'; import { sleep } from '@/infrastructure/Threading/AsyncSleep';
import CardSelectionIndicator from './CardSelectionIndicator.vue'; import CardSelectionIndicator from './CardSelectionIndicator.vue';
import CardExpandTransition from './CardExpandTransition.vue'; import CardExpandTransition from './CardExpandTransition.vue';
import CardExpansionPanel from './CardExpansionPanel.vue'; import CardExpansionArrow from './CardExpansionArrow.vue';
import type { CardLayout } from './UseCardLayout';
export default defineComponent({ export default defineComponent({
components: { components: {
ScriptsTree,
AppIcon, AppIcon,
CardSelectionIndicator, CardSelectionIndicator,
CardExpansionPanel, FlatButton,
CardExpandTransition, CardExpandTransition,
CardExpansionArrow,
}, },
props: { props: {
categoryId: { categoryId: {
type: Number, type: Number,
required: true, required: true,
}, },
cardLayout: {
type: Object as PropType<CardLayout>,
required: true,
},
activeCategoryId: { activeCategoryId: {
type: Number, type: Number,
default: undefined, default: undefined,
@@ -117,7 +129,6 @@ export default defineComponent({
cardTitle, cardTitle,
isExpanded, isExpanded,
cardElement, cardElement,
totalColumns: props.cardLayout.totalColumns,
collapse, collapse,
}; };
}, },
@@ -130,6 +141,7 @@ export default defineComponent({
@use "./card-gap" as *; @use "./card-gap" as *;
$card-inner-padding : $spacing-absolute-xx-large; $card-inner-padding : $spacing-absolute-xx-large;
$expanded-margin-top : $spacing-absolute-xx-large;
$card-horizontal-gap : $card-gap; $card-horizontal-gap : $card-gap;
.card { .card {
@@ -178,13 +190,44 @@ $card-horizontal-gap : $card-gap;
font-size: $font-size-absolute-normal; font-size: $font-size-absolute-normal;
} }
} }
.card__expander {
position: relative;
background-color: $color-primary-darker;
color: $color-on-primary;
display: flex;
align-items: center;
flex-direction: column;
.card__expander__content {
display: flex;
justify-content: center;
word-break: break-word;
max-width: 100%; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
width: 100%; // Expands the container to fill available horizontal space, enabling alignment of child items.
}
.card__expander__close-button {
font-size: $font-size-absolute-large;
align-self: flex-end;
margin-right: $spacing-absolute-small;
@include clickable;
color: $color-primary-light;
@include hover-or-touch {
color: $color-primary;
}
}
}
&.is-expanded { &.is-expanded {
.card__inner { .card__inner {
height: auto; height: auto;
background-color: $color-secondary; background-color: $color-secondary;
color: $color-on-secondary; color: $color-on-secondary;
margin-bottom: $spacing-absolute-xx-large; }
.card__expander {
margin-top: $expanded-margin-top;
} }
@include hover-or-touch { @include hover-or-touch {
@@ -210,32 +253,36 @@ $card-horizontal-gap : $card-gap;
} }
} }
} }
@mixin adaptive-card($cards-in-row) {
.card { &.card {
$total-columns: v-bind(totalColumns); $total-times-gap-is-used-in-row: $cards-in-row - 1;
$total-times-gap-is-used-in-row: calc($total-columns - 1); $total-gap-width-in-row: $total-times-gap-is-used-in-row * $card-horizontal-gap;
$total-gap-width-in-row: calc($total-times-gap-is-used-in-row * $card-horizontal-gap);
$available-row-width-for-cards: calc(100% - #{$total-gap-width-in-row}); $available-row-width-for-cards: calc(100% - #{$total-gap-width-in-row});
$available-width-per-card: calc(#{$available-row-width-for-cards} / $total-columns); $available-width-per-card: calc(#{$available-row-width-for-cards} / #{$cards-in-row});
width:$available-width-per-card; width:$available-width-per-card;
:deep(.card__expander) { .card__expander {
$all-cards-width: calc(100% * $total-columns); $all-cards-width: 100% * $cards-in-row;
$additional-padding-width: calc($card-horizontal-gap * ($total-columns - 1)); $additional-padding-width: $card-horizontal-gap * ($cards-in-row - 1);
width: calc(#{$all-cards-width} + #{$additional-padding-width}); width: calc(#{$all-cards-width} + #{$additional-padding-width});
} }
// @for $nth-card from 2 through $total-columns { // From second card to rest @for $nth-card from 2 through $cards-in-row { // From second card to rest
// &:nth-of-type(#{$total-columns}n+#{$nth-card}) { &:nth-of-type(#{$cards-in-row}n+#{$nth-card}) {
// :deep(.card__expander) { .card__expander {
// $card-left: -100% * ($nth-card - 1); $card-left: -100% * ($nth-card - 1);
// $additional-space: $card-horizontal-gap * ($nth-card - 1); $additional-space: $card-horizontal-gap * ($nth-card - 1);
// margin-left: calc(#{$card-left} - #{$additional-space}); margin-left: calc(#{$card-left} - #{$additional-space});
// } }
// } }
// } }
// Ensure new line after last row // Ensure new line after last row
$card-after-last: $total-columns + 1; $card-after-last: $cards-in-row + 1;
&:nth-of-type(#{$total-columns}n+#{$card-after-last}) { &:nth-of-type(#{$cards-in-row}n+#{$card-after-last}) {
clear: left; clear: left;
} }
}
} }
.big-screen { @include adaptive-card(3); }
.medium-screen { @include adaptive-card(2); }
.small-screen { @include adaptive-card(1); }
</style> </style>

View File

@@ -1,68 +0,0 @@
import { computed, type Ref } from 'vue';
export function useCardLayout(options: {
readonly containerWidth: Readonly<Ref<number>>;
readonly totalCards: Readonly<Ref<number>>;
}): Readonly<Ref<CardLayout>> {
return computed(() => {
return determineCardLayout(
options.containerWidth.value,
options.totalCards.value,
);
});
}
export interface CardLayout {
readonly totalRows: number;
readonly totalColumns: number;
readonly availableCardWidth: number;
}
function determineCardLayout(
containerWidth: number,
totalCards: number,
): CardLayout {
const containerSize = getContainerSize(containerWidth);
const totalColumns = countTotalColumns(containerSize);
const totalRows = countTotalRows(totalColumns, totalCards);
return {
totalColumns,
totalRows,
availableCardWidth: containerWidth / totalRows,
};
}
enum ContainerSize {
Small,
Medium,
Big,
}
function countTotalRows(totalColumns: number, totalCards: number): number {
return Math.ceil(totalCards / totalColumns);
}
function countTotalColumns(size: ContainerSize): number {
switch (size) {
case ContainerSize.Small:
return 1;
case ContainerSize.Medium:
return 2;
case ContainerSize.Big:
return 3;
default:
throw new Error(`Unknown size: ${size}`);
}
}
function getContainerSize(containerWidth: number): ContainerSize {
const smallBreakpoint = 500;
const bigBreakpoint = 750;
if (containerWidth <= smallBreakpoint) {
return ContainerSize.Small;
}
if (containerWidth < bigBreakpoint) {
return ContainerSize.Medium;
}
return ContainerSize.Big;
}

View File

@@ -1,395 +1,258 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { CategoryData, CategoryOrScriptData } from '@/application/collections/'; import type { CategoryData, CategoryOrScriptData } from '@/application/collections/';
import { type CategoryFactory, parseCategory } from '@/application/Parser/CategoryParser'; import { type CategoryFactoryType, parseCategory } from '@/application/Parser/CategoryParser';
import { type ScriptParser } from '@/application/Parser/Script/ScriptParser'; import { parseScript } from '@/application/Parser/Script/ScriptParser';
import { type DocsParser } from '@/application/Parser/DocumentationParser'; import { parseDocs } from '@/application/Parser/DocumentationParser';
import { ScriptCompilerStub } from '@tests/unit/shared/Stubs/ScriptCompilerStub';
import { CategoryCollectionParseContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionParseContextStub'; import { CategoryCollectionParseContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionParseContextStub';
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub'; import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
import { getAbsentCollectionTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { NodeDataType } from '@/application/Parser/NodeValidation/NodeDataType'; import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import { expectThrowsNodeError, type ITestScenario, NodeValidationTestRunner } from '@tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner';
import type { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext'; import type { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext';
import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub'; import { Category } from '@/domain/Category';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import { createScriptDataWithCall, createScriptDataWithCode, createScriptDataWithoutCallOrCodes } from '@tests/unit/shared/Stubs/ScriptDataStub';
import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub';
import type { NodeDataValidatorFactory } from '@/application/Parser/NodeValidation/NodeDataValidator';
import { NodeDataValidatorStub, createNodeDataValidatorFactoryStub } from '@tests/unit/shared/Stubs/NodeDataValidatorStub';
import type { CategoryNodeErrorContext, UnknownNodeErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { createCategoryFactorySpy } from '@tests/unit/shared/Stubs/CategoryFactoryStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { ScriptParserStub } from '@tests/unit/shared/Stubs/ScriptParserStub';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
import { itThrowsContextualError } from './ContextualErrorTester';
import { itValidatesName, itValidatesDefinedData, itAsserts } from './NodeDataValidationTester';
import { generateDataValidationTestScenarios } from './DataValidationTestScenarioGenerator';
describe('CategoryParser', () => { describe('CategoryParser', () => {
describe('parseCategory', () => { describe('parseCategory', () => {
describe('validation', () => { describe('invalid category data', () => {
describe('validates for name', () => { describe('validates script data', () => {
// arrange describe('satisfies shared node tests', () => {
const expectedName = 'expected category name to be validated'; new NodeValidationTestRunner()
const category = new CategoryDataStub() .testInvalidNodeName((invalidName) => {
.withName(expectedName); return createTest(
const expectedContext: CategoryNodeErrorContext = { new CategoryDataStub().withName(invalidName),
type: NodeDataType.Category,
selfNode: category,
};
itValidatesName((validatorFactory) => {
// act
new TestBuilder()
.withData(category)
.withValidatorFactory(validatorFactory)
.parseCategory();
// assert
return {
expectedNameToValidate: expectedName,
expectedErrorContext: expectedContext,
};
});
});
describe('validates for defined data', () => {
// arrange
const category = new CategoryDataStub();
const expectedContext: CategoryNodeErrorContext = {
type: NodeDataType.Category,
selfNode: category,
};
itValidatesDefinedData(
(validatorFactory) => {
// act
new TestBuilder()
.withData(category)
.withValidatorFactory(validatorFactory)
.parseCategory();
// assert
return {
expectedDataToValidate: category,
expectedErrorContext: expectedContext,
};
},
); );
})
.testMissingNodeData((node) => {
return createTest(node as CategoryData);
}); });
describe('validates that category has some children', () => { });
describe('throws when category children is absent', () => {
itEachAbsentCollectionValue<CategoryOrScriptData>((absentValue) => {
// arrange
const categoryName = 'test'; const categoryName = 'test';
const testScenarios = generateDataValidationTestScenarios<CategoryData>({
expectFail: getAbsentCollectionTestCases<CategoryOrScriptData>().map(({
valueName, absentValue: absentCollectionValue,
}) => ({
description: `with \`${valueName}\` value as children`,
data: new CategoryDataStub()
.withName(categoryName)
.withChildren(absentCollectionValue as unknown as CategoryOrScriptData[]),
})),
expectPass: [{
description: 'has single children',
data: new CategoryDataStub()
.withName(categoryName)
.withChildren([createScriptDataWithCode()]),
}],
});
testScenarios.forEach(({
description, expectedPass, data: categoryData,
}) => {
describe(description, () => {
itAsserts({
expectedConditionResult: expectedPass,
test: (validatorFactory) => {
const expectedMessage = `"${categoryName}" has no children.`; const expectedMessage = `"${categoryName}" has no children.`;
const expectedContext: CategoryNodeErrorContext = { const category = new CategoryDataStub()
type: NodeDataType.Category, .withName(categoryName)
selfNode: categoryData, .withChildren(absentValue);
};
// act // act
try { const test = createTest(category);
new TestBuilder()
.withData(categoryData)
.withValidatorFactory(validatorFactory)
.parseCategory();
} catch { /* It may throw due to assertions not being evaluated */ }
// assert // assert
return { expectThrowsNodeError(test, expectedMessage);
expectedErrorMessage: expectedMessage, }, { excludeUndefined: true, excludeNull: true });
expectedErrorContext: expectedContext,
};
},
}); });
}); describe('throws when category child is missing', () => {
}); new NodeValidationTestRunner()
}); .testMissingNodeData((missingNode) => {
describe('validates that a child is a category or a script', () => {
// arrange // arrange
const testScenarios = generateDataValidationTestScenarios<CategoryOrScriptData>({ const invalidChildNode = missingNode;
expectFail: [{ const parent = new CategoryDataStub()
description: 'child has incorrect properties', .withName('parent')
data: { property: 'non-empty-value' } as unknown as CategoryOrScriptData, .withChildren([new CategoryDataStub().withName('valid child'), invalidChildNode]);
}], return ({
expectPass: [ // act
{ act: () => new TestBuilder()
description: 'child is a category', .withData(parent)
data: new CategoryDataStub(), .parseCategory(),
// assert
expectedContext: {
selfNode: invalidChildNode,
parentNode: parent,
}, },
{
description: 'child is a script with call',
data: createScriptDataWithCall(),
},
{
description: 'child is a script with code',
data: createScriptDataWithCode(),
},
],
}); });
testScenarios.forEach(({ });
description, expectedPass, data: childData, });
}) => { it('throws when node is neither a category or a script', () => {
describe(description, () => { // arrange
itAsserts({
expectedConditionResult: expectedPass,
test: (validatorFactory) => {
const expectedError = 'Node is neither a category or a script.'; const expectedError = 'Node is neither a category or a script.';
const invalidChildNode = { property: 'non-empty-value' } as never as CategoryOrScriptData;
const parent = new CategoryDataStub() const parent = new CategoryDataStub()
.withName('parent') .withName('parent')
.withChildren([new CategoryDataStub().withName('valid child'), childData]); .withChildren([new CategoryDataStub().withName('valid child'), invalidChildNode]);
const expectedContext: UnknownNodeErrorContext = { // act
selfNode: childData, const test: ITestScenario = {
// act
act: () => new TestBuilder()
.withData(parent)
.parseCategory(),
// assert
expectedContext: {
selfNode: invalidChildNode,
parentNode: parent, parentNode: parent,
};
// act
new TestBuilder()
.withData(parent)
.withValidatorFactory(validatorFactory)
.parseCategory();
// assert
return {
expectedErrorMessage: expectedError,
expectedErrorContext: expectedContext,
};
}, },
};
// assert
expectThrowsNodeError(test, expectedError);
}); });
}); describe('throws when category child is invalid category', () => {
}); new NodeValidationTestRunner().testInvalidNodeName((invalidName) => {
});
describe('validates children recursively', () => {
describe('validates (1th-level) child data', () => {
// arrange // arrange
const expectedName = 'child category'; const invalidChildNode = new CategoryDataStub()
const child = new CategoryDataStub() .withName(invalidName);
.withName(expectedName);
const parent = new CategoryDataStub() const parent = new CategoryDataStub()
.withName('parent') .withName('parent')
.withChildren([child]); .withChildren([new CategoryDataStub().withName('valid child'), invalidChildNode]);
const expectedContext: UnknownNodeErrorContext = { return ({
selfNode: child, // act
act: () => new TestBuilder()
.withData(parent)
.parseCategory(),
// assert
expectedContext: {
type: NodeType.Category,
selfNode: invalidChildNode,
parentNode: parent, parentNode: parent,
};
itValidatesDefinedData(
(validatorFactory) => {
// act
new TestBuilder()
.withData(parent)
.withValidatorFactory(validatorFactory)
.parseCategory();
// assert
return {
expectedDataToValidate: child,
expectedErrorContext: expectedContext,
};
}, },
);
}); });
describe('validates that (2nd-level) child name', () => { });
// arrange });
const expectedName = 'grandchild category'; function createTest(category: CategoryData): ITestScenario {
const grandChild = new CategoryDataStub()
.withName(expectedName);
const child = new CategoryDataStub()
.withChildren([grandChild])
.withName('child category');
const parent = new CategoryDataStub()
.withName('parent')
.withChildren([child]);
const expectedContext: CategoryNodeErrorContext = {
type: NodeDataType.Category,
selfNode: grandChild,
parentNode: child,
};
itValidatesName((validatorFactory) => {
// act
new TestBuilder()
.withData(parent)
.withValidatorFactory(validatorFactory)
.parseCategory();
// assert
return { return {
expectedNameToValidate: expectedName, act: () => new TestBuilder()
expectedErrorContext: expectedContext, .withData(category)
}; .parseCategory(),
}); expectedContext: {
}); type: NodeType.Category,
}); selfNode: category,
});
describe('rethrows exception if category factory fails', () => {
// arrange
const givenData = new CategoryDataStub();
const expectedContextMessage = 'Failed to parse category.';
const expectedError = new Error();
// act & assert
itThrowsContextualError({
throwingAction: (wrapError) => {
const validatorStub = new NodeDataValidatorStub();
validatorStub.createContextualErrorMessage = (message) => message;
const factoryMock: CategoryFactory = () => {
throw expectedError;
};
new TestBuilder()
.withCategoryFactory(factoryMock)
.withValidatorFactory(() => validatorStub)
.withErrorWrapper(wrapError)
.withData(givenData)
.parseCategory();
}, },
expectedWrappedError: expectedError, };
expectedContextMessage, }
});
it(`rethrows exception if ${Category.name} cannot be constructed`, () => {
// arrange
const expectedError = 'category creation failed';
const factoryMock: CategoryFactoryType = () => { throw new Error(expectedError); };
const data = new CategoryDataStub();
// act
const act = () => new TestBuilder()
.withData(data)
.withFactory(factoryMock)
.parseCategory();
// expect
expectThrowsNodeError({
act,
expectedContext: {
type: NodeType.Category,
selfNode: data,
},
}, expectedError);
}); });
}); });
it('parses docs correctly', () => { it('returns expected docs', () => {
// arrange // arrange
const url = 'https://privacy.sexy'; const url = 'https://privacy.sexy';
const categoryData = new CategoryDataStub() const expected = parseDocs({ docs: url });
const category = new CategoryDataStub()
.withDocs(url); .withDocs(url);
const parseDocs: DocsParser = (data) => {
return [
`parsed docs: ${JSON.stringify(data)}`,
];
};
const expectedDocs = parseDocs(categoryData);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act // act
const actualCategory = new TestBuilder() const actual = new TestBuilder()
.withData(categoryData) .withData(category)
.withCategoryFactory(categoryFactorySpy) .parseCategory()
.withDocsParser(parseDocs) .docs;
.parseCategory();
// assert // assert
const actualDocs = getInitParameters(actualCategory)?.docs; expect(actual).to.deep.equal(expected);
expect(actualDocs).to.deep.equal(expectedDocs);
}); });
describe('parses expected subscript', () => { describe('parses expected subscript', () => {
it('parses single script correctly', () => { it('single script with code', () => {
// arrange // arrange
const expectedScript = new ScriptStub('expected script'); const script = createScriptDataWithCode();
const scriptParser = new ScriptParserStub(); const context = new CategoryCollectionParseContextStub();
const childScriptData = createScriptDataWithCode(); const expected = [parseScript(script, context)];
const categoryData = new CategoryDataStub() const category = new CategoryDataStub()
.withChildren([childScriptData]); .withChildren([script]);
scriptParser.setupParsedResultForData(childScriptData, expectedScript);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act // act
const actualCategory = new TestBuilder() const actual = new TestBuilder()
.withData(categoryData) .withData(category)
.withScriptParser(scriptParser.get()) .withContext(context)
.withCategoryFactory(categoryFactorySpy) .parseCategory()
.parseCategory(); .scripts;
// assert // assert
const actualScripts = getInitParameters(actualCategory)?.scripts; expect(actual).to.deep.equal(expected);
expectExists(actualScripts);
expect(actualScripts).to.have.lengthOf(1);
const actualScript = actualScripts[0];
expect(actualScript).to.equal(expectedScript);
}); });
it('parses multiple scripts correctly', () => { it('single script with function call', () => {
// arrange // arrange
const expectedScripts = [ const script = createScriptDataWithCall();
new ScriptStub('expected-first-script'), const compiler = new ScriptCompilerStub()
new ScriptStub('expected-second-script'), .withCompileAbility(script);
]; const context = new CategoryCollectionParseContextStub()
const childrenData = [ .withCompiler(compiler);
createScriptDataWithCall(), const expected = [parseScript(script, context)];
createScriptDataWithCode(), const category = new CategoryDataStub()
]; .withChildren([script]);
const scriptParser = new ScriptParserStub();
childrenData.forEach((_, index) => {
scriptParser.setupParsedResultForData(childrenData[index], expectedScripts[index]);
});
const categoryData = new CategoryDataStub()
.withChildren(childrenData);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act // act
const actualCategory = new TestBuilder() const actual = new TestBuilder()
.withScriptParser(scriptParser.get()) .withData(category)
.withData(categoryData) .withContext(context)
.withCategoryFactory(categoryFactorySpy) .parseCategory()
.parseCategory(); .scripts;
// assert // assert
const actualParsedScripts = getInitParameters(actualCategory)?.scripts; expect(actual).to.deep.equal(expected);
expectExists(actualParsedScripts);
expect(actualParsedScripts.length).to.equal(expectedScripts.length);
expect(actualParsedScripts).to.have.members(expectedScripts);
}); });
it('parses all scripts with correct context', () => { it('multiple scripts with function call and code', () => {
// arrange // arrange
const expectedParseContext = new CategoryCollectionParseContextStub(); const callableScript = createScriptDataWithCall();
const scriptParser = new ScriptParserStub(); const scripts = [callableScript, createScriptDataWithCode()];
const childrenData = [ const category = new CategoryDataStub()
createScriptDataWithCode(), .withChildren(scripts);
createScriptDataWithCode(), const compiler = new ScriptCompilerStub()
createScriptDataWithCode(), .withCompileAbility(callableScript);
]; const context = new CategoryCollectionParseContextStub()
const categoryData = new CategoryDataStub() .withCompiler(compiler);
.withChildren(childrenData); const expected = scripts.map((script) => parseScript(script, context));
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act // act
const actualCategory = new TestBuilder() const actual = new TestBuilder()
.withData(categoryData) .withData(category)
.withContext(expectedParseContext) .withContext(context)
.withScriptParser(scriptParser.get()) .parseCategory()
.withCategoryFactory(categoryFactorySpy) .scripts;
.parseCategory();
// assert // assert
const actualParsedScripts = getInitParameters(actualCategory)?.scripts; expect(actual).to.deep.equal(expected);
expectExists(actualParsedScripts); });
const actualParseContexts = actualParsedScripts.map( it('script is created with right context', () => { // test through script validation logic
(s) => scriptParser.getParseParameters(s)[1], // arrange
); const commentDelimiter = 'should not throw';
expect( const duplicatedCode = `${commentDelimiter} duplicate-line\n${commentDelimiter} duplicate-line`;
actualParseContexts.every( const parseContext = new CategoryCollectionParseContextStub()
(actualParseContext) => actualParseContext === expectedParseContext, .withSyntax(new LanguageSyntaxStub().withCommentDelimiters(commentDelimiter));
), const category = new CategoryDataStub()
formatAssertionMessage([ .withChildren([
`Expected all elements to be ${JSON.stringify(expectedParseContext)}`, new CategoryDataStub()
'All elements:', .withName('sub-category')
indentText(JSON.stringify(actualParseContexts)), .withChildren([
createScriptDataWithoutCallOrCodes()
.withCode(duplicatedCode),
]), ]),
).to.equal(true); ]);
// act
const act = () => new TestBuilder()
.withData(category)
.withContext(parseContext)
.parseCategory()
.scripts;
// assert
expect(act).to.not.throw();
}); });
}); });
it('returns expected subcategories', () => { it('returns expected subcategories', () => {
// arrange // arrange
const expectedChildCategory = new CategoryStub(33); const expected = [new CategoryDataStub()
const childCategoryData = new CategoryDataStub() .withName('test category')
.withName('expected child category') .withChildren([createScriptDataWithCode()]),
.withChildren([createScriptDataWithCode()]); ];
const categoryData = new CategoryDataStub() const category = new CategoryDataStub()
.withName('category name') .withName('category name')
.withChildren([childCategoryData]); .withChildren(expected);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act // act
const actualCategory = new TestBuilder() const actual = new TestBuilder()
.withData(categoryData) .withData(category)
.withCategoryFactory((parameters) => { .parseCategory()
if (parameters.name === childCategoryData.category) { .subCategories;
return expectedChildCategory;
}
return categoryFactorySpy(parameters);
})
.parseCategory();
// assert // assert
const actualSubcategories = getInitParameters(actualCategory)?.subcategories; expect(actual).to.have.lengthOf(1);
expectExists(actualSubcategories); expect(actual[0].name).to.equal(expected[0].category);
expect(actualSubcategories).to.have.lengthOf(1); expect(actual[0].scripts.length).to.equal(expected[0].children.length);
expect(actualSubcategories[0]).to.equal(expectedChildCategory);
}); });
}); });
}); });
@@ -399,62 +262,24 @@ class TestBuilder {
private context: ICategoryCollectionParseContext = new CategoryCollectionParseContextStub(); private context: ICategoryCollectionParseContext = new CategoryCollectionParseContextStub();
private categoryFactory: CategoryFactory = () => new CategoryStub(33); private factory?: CategoryFactoryType = undefined;
private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get();
private validatorFactory: NodeDataValidatorFactory = createNodeDataValidatorFactoryStub;
private docsParser: DocsParser = () => ['docs'];
private scriptParser: ScriptParser = new ScriptParserStub().get();
public withData(data: CategoryData) { public withData(data: CategoryData) {
this.data = data; this.data = data;
return this; return this;
} }
public withContext(context: ICategoryCollectionParseContext): this { public withContext(context: ICategoryCollectionParseContext) {
this.context = context; this.context = context;
return this; return this;
} }
public withCategoryFactory(categoryFactory: CategoryFactory): this { public withFactory(factory: CategoryFactoryType) {
this.categoryFactory = categoryFactory; this.factory = factory;
return this;
}
public withValidatorFactory(validatorFactory: NodeDataValidatorFactory): this {
this.validatorFactory = validatorFactory;
return this;
}
public withErrorWrapper(errorWrapper: ErrorWithContextWrapper): this {
this.errorWrapper = errorWrapper;
return this;
}
public withScriptParser(scriptParser: ScriptParser): this {
this.scriptParser = scriptParser;
return this;
}
public withDocsParser(docsParser: DocsParser): this {
this.docsParser = docsParser;
return this; return this;
} }
public parseCategory() { public parseCategory() {
return parseCategory( return parseCategory(this.data, this.context, this.factory);
this.data,
this.context,
{
createCategory: this.categoryFactory,
wrapError: this.errorWrapper,
createValidator: this.validatorFactory,
parseScript: this.scriptParser,
parseDocs: this.docsParser,
},
);
} }
} }

View File

@@ -1,121 +0,0 @@
import { describe, it, expect } from 'vitest';
import { CustomError } from '@/application/Common/CustomError';
import { wrapErrorWithAdditionalContext } from '@/application/Parser/ContextualError';
describe('wrapErrorWithAdditionalContext', () => {
it('preserves the original error when wrapped', () => {
// arrange
const expectedError = new Error();
const context = new TestContext()
.withError(expectedError);
// act
const error = context.wrap();
// assert
const actualError = extractInnerErrorFromContextualError(error);
expect(actualError).to.equal(expectedError);
});
it('maintains the original error when re-wrapped', () => {
// arrange
const expectedError = new Error();
// act
const firstError = new TestContext()
.withError(expectedError)
.withAdditionalContext('first error')
.wrap();
const secondError = new TestContext()
.withError(firstError)
.withAdditionalContext('second error')
.wrap();
// assert
const actualError = extractInnerErrorFromContextualError(secondError);
expect(actualError).to.equal(expectedError);
});
it(`the object extends ${CustomError.name}`, () => {
// arrange
const expected = CustomError;
// act
const error = new TestContext()
.wrap();
// assert
expect(error).to.be.an.instanceof(expected);
});
describe('error message construction', () => {
it('includes the message from the original error', () => {
// arrange
const expectedOriginalErrorMessage = 'Message from the inner error';
// act
const error = new TestContext()
.withError(new Error(expectedOriginalErrorMessage))
.wrap();
// assert
expect(error.message).contains(expectedOriginalErrorMessage);
});
it('appends provided additional context to the error message', () => {
// arrange
const expectedAdditionalContext = 'Expected additional context message';
// act
const error = new TestContext()
.withAdditionalContext(expectedAdditionalContext)
.wrap();
// assert
expect(error.message).contains(expectedAdditionalContext);
});
it('appends multiple contexts to the error message in sequential order', () => {
// arrange
const expectedFirstContext = 'First context';
const expectedSecondContext = 'Second context';
// act
const firstError = new TestContext()
.withAdditionalContext(expectedFirstContext)
.wrap();
const secondError = new TestContext()
.withError(firstError)
.withAdditionalContext(expectedSecondContext)
.wrap();
// assert
const messageLines = secondError.message.split('\n');
expect(messageLines).to.contain(`1: ${expectedFirstContext}`);
expect(messageLines).to.contain(`2: ${expectedSecondContext}`);
});
});
});
class TestContext {
private error: Error = new Error();
private additionalContext = `[${TestContext.name}] additional context`;
public withError(error: Error) {
this.error = error;
return this;
}
public withAdditionalContext(additionalContext: string) {
this.additionalContext = additionalContext;
return this;
}
public wrap(): ReturnType<typeof wrapErrorWithAdditionalContext> {
return wrapErrorWithAdditionalContext(
this.error,
this.additionalContext,
);
}
}
function extractInnerErrorFromContextualError(error: Error): Error {
const innerErrorProperty = 'innerError';
if (!(innerErrorProperty in error)) {
throw new Error(`${innerErrorProperty} property is missing`);
}
const actualError = error[innerErrorProperty];
return actualError as Error;
}

View File

@@ -1,53 +0,0 @@
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub';
interface ContextualErrorTestScenario {
readonly throwingAction: (wrapError: ErrorWithContextWrapper) => void;
readonly expectedWrappedError: Error;
readonly expectedContextMessage: string;
}
export function itThrowsContextualError(
testScenario: ContextualErrorTestScenario,
) {
it('throws wrapped error', () => {
// arrange
const expectedError = new Error();
const wrapperStub = new ErrorWrapperStub()
.withError(expectedError);
// act
const act = () => testScenario.throwingAction(wrapperStub.get());
// assert
expect(act).to.throw(expectedError);
});
it('wraps internal error', () => {
// arrange
const expectedInternalError = testScenario.expectedWrappedError;
const wrapperStub = new ErrorWrapperStub();
// act
try {
testScenario.throwingAction(wrapperStub.get());
} catch { /* Swallow */ }
// assert
expect(wrapperStub.lastError).to.deep.equal(expectedInternalError);
});
it('includes expected context', () => {
// arrange
const { expectedContextMessage: expectedContext } = testScenario;
const wrapperStub = new ErrorWrapperStub();
// act
try {
testScenario.throwingAction(wrapperStub.get());
} catch { /* Swallow */ }
// assert
expectExists(wrapperStub.lastContext);
expect(wrapperStub.lastContext).to.equal(expectedContext, formatAssertionMessage([
'Unexpected additional context (additional message added to the wrapped error).',
`Actual additional context:\n${indentText(wrapperStub.lastContext)}`,
`Expected additional context:\n${indentText(expectedContext)}`,
]));
});
}

View File

@@ -1,36 +0,0 @@
export interface DataValidationTestScenario<T> {
readonly description: string;
readonly data: T;
readonly expectedPass: boolean;
readonly expectedMessage?: string;
}
export function generateDataValidationTestScenarios<T>(
...conditionBasedScenarios: DataValidationConditionBasedTestScenario<T>[]
): DataValidationTestScenario<T>[] {
return conditionBasedScenarios.flatMap((conditionScenario) => [
conditionScenario.expectFail.map((failDefinition): DataValidationTestScenario<T> => ({
description: `fails: "${failDefinition.description}"`,
data: failDefinition.data,
expectedPass: false,
expectedMessage: conditionScenario.assertErrorMessage,
})),
conditionScenario.expectPass.map((passDefinition): DataValidationTestScenario<T> => ({
description: `passes: "${passDefinition.description}"`,
data: passDefinition.data,
expectedPass: true,
expectedMessage: conditionScenario.assertErrorMessage,
})),
].flat());
}
interface DataValidationConditionBasedTestScenario<T> {
readonly assertErrorMessage?: string;
readonly expectPass: readonly DataValidationScenarioDefinition<T>[];
readonly expectFail: readonly DataValidationScenarioDefinition<T>[];
}
interface DataValidationScenarioDefinition<T> {
readonly description: string;
readonly data: T;
}

View File

@@ -1,213 +0,0 @@
import { it } from 'vitest';
import type { NodeDataValidator, NodeDataValidatorFactory } from '@/application/Parser/NodeValidation/NodeDataValidator';
import type { NodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext';
import { NodeDataValidatorStub } from '@tests/unit/shared/Stubs/NodeDataValidatorStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import type { CategoryOrScriptData } from '@/application/collections/';
import type { FunctionKeys } from '@/TypeHelpers';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
type NodeValidationTestFunction<TExpectation> = (
factory: NodeDataValidatorFactory,
) => TExpectation;
interface ValidNameExpectation {
readonly expectedNameToValidate: string;
readonly expectedErrorContext: NodeDataErrorContext;
}
export function itValidatesName(
test: NodeValidationTestFunction<ValidNameExpectation>,
) {
it('validates for name', () => {
// arrange
const validator = new NodeDataValidatorStub();
const factoryStub: NodeDataValidatorFactory = () => validator;
// act
test(factoryStub);
// assert
const call = validator.callHistory.find((c) => c.methodName === 'assertValidName');
expectExists(call);
});
it('validates for name with correct name', () => {
// arrange
const validator = new NodeDataValidatorStub();
const factoryStub: NodeDataValidatorFactory = () => validator;
// act
const expectation = test(factoryStub);
// assert
const expectedName = expectation.expectedNameToValidate;
const names = validator.callHistory
.filter((c) => c.methodName === 'assertValidName')
.flatMap((c) => c.args[0]);
expect(names).to.include(expectedName);
});
it('validates for name with correct context', () => {
expectCorrectContextForFunctionCall({
methodName: 'assertValidName',
act: test,
expectContext: (expectation) => expectation.expectedErrorContext,
});
});
}
interface ValidDataExpectation {
readonly expectedDataToValidate: CategoryOrScriptData;
readonly expectedErrorContext: NodeDataErrorContext;
}
export function itValidatesDefinedData(
test: NodeValidationTestFunction<ValidDataExpectation>,
) {
it('validates data', () => {
// arrange
const validator = new NodeDataValidatorStub();
const factoryStub: NodeDataValidatorFactory = () => validator;
// act
test(factoryStub);
// assert
const call = validator.callHistory.find((c) => c.methodName === 'assertDefined');
expectExists(call);
});
it('validates data with correct data', () => {
// arrange
const validator = new NodeDataValidatorStub();
const factoryStub: NodeDataValidatorFactory = () => validator;
// act
const expectation = test(factoryStub);
// assert
const expectedData = expectation.expectedDataToValidate;
const calls = validator.callHistory.filter((c) => c.methodName === 'assertDefined');
const names = calls.flatMap((c) => c.args[0]);
expect(names).to.include(expectedData);
});
it('validates data with correct context', () => {
expectCorrectContextForFunctionCall({
methodName: 'assertDefined',
act: test,
expectContext: (expectation) => expectation.expectedErrorContext,
});
});
}
interface AssertionExpectation {
readonly expectedErrorMessage: string;
readonly expectedErrorContext: NodeDataErrorContext;
}
export function itAsserts(
testScenario: {
readonly test: NodeValidationTestFunction<AssertionExpectation>,
readonly expectedConditionResult: boolean;
},
) {
it('asserts with correct message', () => {
// arrange
const validator = new NodeDataValidatorStub()
.withAssertThrowsOnFalseCondition(false);
const factoryStub: NodeDataValidatorFactory = () => validator;
// act
const expectation = testScenario.test(factoryStub);
// assert
const expectedError = expectation.expectedErrorMessage;
const calls = validator.callHistory.filter((c) => c.methodName === 'assert');
const actualMessages = calls.map((call) => {
const [, message] = call.args;
return message;
});
expect(actualMessages).to.include(expectedError);
});
it('asserts with correct context', () => {
expectCorrectContextForFunctionCall({
methodName: 'assert',
act: testScenario.test,
expectContext: (expectation) => expectation.expectedErrorContext,
});
});
it('asserts with correct condition result', () => {
// arrange
const expectedEvaluationResult = testScenario.expectedConditionResult;
const validator = new NodeDataValidatorStub()
.withAssertThrowsOnFalseCondition(false);
const factoryStub: NodeDataValidatorFactory = () => validator;
// act
const expectation = testScenario.test(factoryStub);
// assert
const assertCalls = validator.callHistory
.filter((call) => call.methodName === 'assert');
expect(assertCalls).to.have.length.greaterThan(0);
const assertCallsWithMessage = assertCalls
.filter((call) => {
const [, message] = call.args;
return message === expectation.expectedErrorMessage;
});
expect(assertCallsWithMessage).to.have.length.greaterThan(0);
const evaluationResults = assertCallsWithMessage
.map((call) => {
const [predicate] = call.args;
return predicate as (() => boolean);
})
.map((predicate) => predicate());
expect(evaluationResults).to.include(expectedEvaluationResult);
});
}
function expectCorrectContextForFunctionCall<T>(testScenario: {
methodName: FunctionKeys<NodeDataValidator>,
act: NodeValidationTestFunction<T>,
expectContext: (actionResult: T) => NodeDataErrorContext,
}) {
// arrange
const { methodName } = testScenario;
const createdValidators = new Array<{
readonly validator: NodeDataValidatorStub;
readonly context: NodeDataErrorContext;
}>();
const factoryStub: NodeDataValidatorFactory = (context) => {
const validator = new NodeDataValidatorStub()
.withAssertThrowsOnFalseCondition(false);
createdValidators.push(({
validator,
context,
}));
return validator;
};
// act
const actionResult = testScenario.act(factoryStub);
// assert
const expectedContext = testScenario.expectContext(actionResult);
const providedContexts = createdValidators
.filter((v) => v.validator.callHistory.find((c) => c.methodName === methodName))
.map((v) => v.context);
expectDeepIncludes( // to.deep.include is not working
providedContexts,
expectedContext,
formatAssertionMessage([
'Error context mismatch.',
'Provided contexts do not include the expected context.',
'Expected context:',
indentText(JSON.stringify(expectedContext, undefined, 2)),
'Provided contexts:',
indentText(JSON.stringify(providedContexts, undefined, 2)),
]),
);
}
function expectDeepIncludes<T>(
array: readonly T[],
item: T,
message: string,
) {
const serializeItem = (c) => JSON.stringify(c);
const serializedContexts = array.map((c) => serializeItem(c));
const serializedExpectedContext = serializeItem(item);
expect(serializedContexts).to.include(serializedExpectedContext, formatAssertionMessage([
'Error context mismatch.',
'Provided contexts do not include the expected context.',
'Expected context:',
indentText(JSON.stringify(message, undefined, 2)),
'Provided contexts:',
indentText(JSON.stringify(message, undefined, 2)),
]));
}

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { type INodeDataErrorContext, NodeDataError } from '@/application/Parser/NodeValidation/NodeDataError';
import { NodeDataErrorContextStub } from '@tests/unit/shared/Stubs/NodeDataErrorContextStub';
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import { CustomError } from '@/application/Common/CustomError';
describe('NodeDataError', () => {
it('sets message as expected', () => {
// arrange
const message = 'message';
const context = new NodeDataErrorContextStub();
const expected = `[${NodeType[context.type]}] ${message}`;
// act
const sut = new NodeDataErrorBuilder()
.withContext(context)
.withMessage(expected)
.build();
// assert
expect(sut.message).to.include(expected);
});
it('sets context as expected', () => {
// arrange
const expected = new NodeDataErrorContextStub();
// act
const sut = new NodeDataErrorBuilder()
.withContext(expected)
.build();
// assert
expect(sut.context).to.equal(expected);
});
it('extends CustomError', () => {
// arrange
const expected = CustomError;
// act
const sut = new NodeDataErrorBuilder()
.build();
// assert
expect(sut).to.be.an.instanceof(expected);
});
});
class NodeDataErrorBuilder {
private message = 'error';
private context: INodeDataErrorContext = new NodeDataErrorContextStub();
public withContext(context: INodeDataErrorContext) {
this.context = context;
return this;
}
public withMessage(message: string) {
this.message = message;
return this;
}
public build(): NodeDataError {
return new NodeDataError(this.message, this.context);
}
}

View File

@@ -1,242 +0,0 @@
import { describe, it, expect } from 'vitest';
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
import type { NodeData } from '@/application/Parser/NodeValidation/NodeData';
import { createNodeDataErrorContextStub } from '@tests/unit/shared/Stubs/NodeDataErrorContextStub';
import type { NodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import { ContextualNodeDataValidator, createNodeDataValidator, type NodeDataValidator } from '@/application/Parser/NodeValidation/NodeDataValidator';
import type { NodeContextErrorMessageCreator } from '@/application/Parser/NodeValidation/NodeDataErrorContextMessage';
import { getAbsentObjectTestCases, getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
describe('createNodeDataValidator', () => {
it(`returns an instance of ${ContextualNodeDataValidator.name}`, () => {
// arrange
const context = createNodeDataErrorContextStub();
// act
const validator = createNodeDataValidator(context);
// assert
expect(validator).to.be.instanceOf(ContextualNodeDataValidator);
});
});
describe('NodeDataValidator', () => {
describe('assertValidName', () => {
describe('throws when name is invalid', () => {
// arrange
const testScenarios: readonly {
readonly description: string;
readonly invalidName: unknown;
readonly expectedMessage: string;
}[] = [
...getAbsentStringTestCases().map((testCase) => ({
description: `missing name (${testCase.valueName})`,
invalidName: testCase.absentValue,
expectedMessage: 'missing name',
})),
{
description: 'invalid type',
invalidName: 33,
expectedMessage: 'Name (33) is not a string but number.',
},
];
testScenarios.forEach(({ description, invalidName, expectedMessage }) => {
describe(`given "${description}"`, () => {
itThrowsCorrectly({
// act
throwingAction: (sut) => {
sut.assertValidName(invalidName as string);
},
// assert
expectedMessage,
});
});
});
});
it('does not throw when name is valid', () => {
// arrange
const validName = 'validName';
const sut = new NodeValidatorBuilder()
.build();
// act
const act = () => sut.assertValidName(validName);
// assert
expect(act).to.not.throw();
});
});
describe('assertDefined', () => {
describe('throws when node data is missing', () => {
// arrange
const testScenarios: readonly {
readonly description: string;
readonly invalidData: unknown;
}[] = [
...getAbsentObjectTestCases().map((testCase) => ({
description: `absent object (${testCase.valueName})`,
invalidData: testCase.absentValue,
})),
{
description: 'empty object',
invalidData: {},
},
];
testScenarios.forEach(({ description, invalidData }) => {
describe(`given "${description}"`, () => {
const expectedMessage = 'missing node data';
itThrowsCorrectly({
// act
throwingAction: (sut: NodeDataValidator) => {
sut.assertDefined(invalidData as NodeData);
},
// assert
expectedMessage,
});
});
});
});
it('does not throw if node data is defined', () => {
// arrange
const definedNode = new CategoryDataStub();
const sut = new NodeValidatorBuilder()
.build();
// act
const act = () => sut.assertDefined(definedNode);
// assert
expect(act).to.not.throw();
});
});
describe('assert', () => {
describe('throws if validation fails', () => {
const falsePredicate = () => false;
const expectedErrorMessage = 'expected error';
// assert
itThrowsCorrectly({
// act
throwingAction: (sut: NodeDataValidator) => {
sut.assert(falsePredicate, expectedErrorMessage);
},
// assert
expectedMessage: expectedErrorMessage,
});
});
it('does not throw if validation succeeds', () => {
// arrange
const truePredicate = () => true;
const sut = new NodeValidatorBuilder()
.build();
// act
const act = () => sut.assert(truePredicate, 'ignored error');
// assert
expect(act).to.not.throw();
});
});
describe('createContextualErrorMessage', () => {
it('creates using the correct error message', () => {
// arrange
const expectedErrorMessage = 'expected error';
const errorMessageBuilder: NodeContextErrorMessageCreator = (message) => message;
const sut = new NodeValidatorBuilder()
.withErrorMessageCreator(errorMessageBuilder)
.build();
// act
const actualErrorMessage = sut.createContextualErrorMessage(expectedErrorMessage);
// assert
expect(actualErrorMessage).to.equal(expectedErrorMessage);
});
it('creates using the correct context', () => {
// arrange
const expectedContext = createNodeDataErrorContextStub();
let actualContext: NodeDataErrorContext | undefined;
const errorMessageBuilder: NodeContextErrorMessageCreator = (_, context) => {
actualContext = context;
return '';
};
const sut = new NodeValidatorBuilder()
.withContext(expectedContext)
.withErrorMessageCreator(errorMessageBuilder)
.build();
// act
sut.createContextualErrorMessage('unimportant');
// assert
expect(actualContext).to.equal(expectedContext);
});
});
});
type ValidationThrowingFunction = (
sut: ContextualNodeDataValidator,
) => void;
interface ValidationThrowingTestScenario {
readonly throwingAction: ValidationThrowingFunction,
readonly expectedMessage: string;
}
function itThrowsCorrectly(
testScenario: ValidationThrowingTestScenario,
): void {
it('throws an error', () => {
// arrange
const expectedErrorMessage = 'Injected error message';
const errorMessageBuilder: NodeContextErrorMessageCreator = () => expectedErrorMessage;
const sut = new NodeValidatorBuilder()
.withErrorMessageCreator(errorMessageBuilder)
.build();
// act
const action = () => testScenario.throwingAction(sut);
// assert
expect(action).to.throw();
});
it('throws with the correct error message', () => {
// arrange
const expectedErrorMessage = testScenario.expectedMessage;
const errorMessageBuilder: NodeContextErrorMessageCreator = (message) => message;
const sut = new NodeValidatorBuilder()
.withErrorMessageCreator(errorMessageBuilder)
.build();
// act
const action = () => testScenario.throwingAction(sut);
// assert
const actualErrorMessage = collectExceptionMessage(action);
expect(actualErrorMessage).to.equal(expectedErrorMessage);
});
it('throws with the correct context', () => {
// arrange
const expectedContext = createNodeDataErrorContextStub();
const serializeContext = (context: NodeDataErrorContext) => JSON.stringify(context);
const errorMessageBuilder:
NodeContextErrorMessageCreator = (_, context) => serializeContext(context);
const sut = new NodeValidatorBuilder()
.withContext(expectedContext)
.withErrorMessageCreator(errorMessageBuilder)
.build();
// act
const action = () => testScenario.throwingAction(sut);
// assert
const expectedSerializedContext = serializeContext(expectedContext);
const actualSerializedContext = collectExceptionMessage(action);
expect(expectedSerializedContext).to.equal(actualSerializedContext);
});
}
class NodeValidatorBuilder {
private errorContext: NodeDataErrorContext = createNodeDataErrorContextStub();
private errorMessageCreator: NodeContextErrorMessageCreator = () => `[${NodeValidatorBuilder.name}] stub error message`;
public withErrorMessageCreator(errorMessageCreator: NodeContextErrorMessageCreator): this {
this.errorMessageCreator = errorMessageCreator;
return this;
}
public withContext(errorContext: NodeDataErrorContext): this {
this.errorContext = errorContext;
return this;
}
public build(): ContextualNodeDataValidator {
return new ContextualNodeDataValidator(
this.errorContext,
this.errorMessageCreator,
);
}
}

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { NodeDataError } from '@/application/Parser/NodeValidation/NodeDataError';
import { NodeValidator } from '@/application/Parser/NodeValidation/NodeValidator';
import { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError';
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
import { NodeDataErrorContextStub } from '@tests/unit/shared/Stubs/NodeDataErrorContextStub';
import type { NodeData } from '@/application/Parser/NodeValidation/NodeData';
import { NodeValidationTestRunner } from './NodeValidatorTestRunner';
describe('NodeValidator', () => {
describe('assertValidName', () => {
describe('throws if invalid', () => {
// arrange
const context = new NodeDataErrorContextStub();
const sut = new NodeValidator(context);
// act
const act = (invalidName: string) => sut.assertValidName(invalidName);
// assert
new NodeValidationTestRunner()
.testInvalidNodeName((invalidName) => ({
act: () => act(invalidName),
expectedContext: context,
}));
});
it('does not throw if valid', () => {
// arrange
const validName = 'validName';
const sut = new NodeValidator(new NodeDataErrorContextStub());
// act
const act = () => sut.assertValidName(validName);
// assert
expect(act).to.not.throw();
});
});
describe('assertDefined', () => {
describe('throws if missing', () => {
// arrange
const context = new NodeDataErrorContextStub();
const sut = new NodeValidator(context);
// act
const act = (undefinedNode: NodeData) => sut.assertDefined(undefinedNode);
// assert
new NodeValidationTestRunner()
.testMissingNodeData((invalidName) => ({
act: () => act(invalidName),
expectedContext: context,
}));
});
it('does not throw if defined', () => {
// arrange
const definedNode = mockNode();
const sut = new NodeValidator(new NodeDataErrorContextStub());
// act
const act = () => sut.assertDefined(definedNode);
// assert
expect(act).to.not.throw();
});
});
describe('assert', () => {
it('throws expected error if condition is false', () => {
// arrange
const message = 'error';
const falsePredicate = () => false;
const context = new NodeDataErrorContextStub();
const expected = new NodeDataError(message, context);
const sut = new NodeValidator(context);
// act
const act = () => sut.assert(falsePredicate, message);
// assert
expectDeepThrowsError(act, expected);
});
it('does not throw if condition is true', () => {
// arrange
const truePredicate = () => true;
const sut = new NodeValidator(new NodeDataErrorContextStub());
// act
const act = () => sut.assert(truePredicate, 'ignored error');
// assert
expect(act).to.not.throw();
});
});
describe('throw', () => {
it('throws expected error', () => {
// arrange
const message = 'error';
const context = new NodeDataErrorContextStub();
const expected = new NodeDataError(message, context);
const sut = new NodeValidator(context);
// act
const act = () => sut.throw(message);
// assert
expectDeepThrowsError(act, expected);
});
});
});
function mockNode() {
return new CategoryDataStub();
}

View File

@@ -0,0 +1,87 @@
import { describe, it } from 'vitest';
import { NodeDataError, type INodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataError';
import type { NodeData } from '@/application/Parser/NodeValidation/NodeData';
import { getAbsentObjectTestCases, getAbsentStringTestCases, itEachAbsentTestCase } from '@tests/unit/shared/TestCases/AbsentTests';
import { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError';
export interface ITestScenario {
readonly act: () => void;
readonly expectedContext: INodeDataErrorContext;
}
export class NodeValidationTestRunner {
public testInvalidNodeName(
testBuildPredicate: (invalidName: string) => ITestScenario,
) {
describe('throws given invalid names', () => {
// arrange
const testCases = [
...getAbsentStringTestCases().map((testCase) => ({
testName: `missing name (${testCase.valueName})`,
nameValue: testCase.absentValue,
expectedMessage: 'missing name',
})),
{
testName: 'invalid type',
nameValue: 33,
expectedMessage: 'Name (33) is not a string but number.',
},
];
for (const testCase of testCases) {
it(`given "${testCase.testName}"`, () => {
const test = testBuildPredicate(testCase.nameValue as never);
expectThrowsNodeError(test, testCase.expectedMessage);
});
}
});
return this;
}
public testMissingNodeData(
testBuildPredicate: (missingNode: NodeData) => ITestScenario,
) {
describe('throws given missing node data', () => {
itEachAbsentTestCase([
...getAbsentObjectTestCases(),
{
valueName: 'empty object',
absentValue: {},
},
], (absentValue) => {
// arrange
const expectedError = 'missing node data';
// act
const test = testBuildPredicate(absentValue as NodeData);
// assert
expectThrowsNodeError(test, expectedError);
});
});
return this;
}
public runThrowingCase(
testCase: {
readonly name: string,
readonly scenario: ITestScenario,
readonly expectedMessage: string
},
) {
it(testCase.name, () => {
expectThrowsNodeError(testCase.scenario, testCase.expectedMessage);
});
return this;
}
}
export function expectThrowsNodeError(
test: ITestScenario,
expectedMessage: string,
) {
// arrange
const expected = new NodeDataError(expectedMessage, test.expectedContext);
// act
const act = () => test.act();
// assert
expectDeepThrowsError(act, expected);
return this;
}

View File

@@ -229,11 +229,7 @@ class ExpressionBuilder {
} }
public build() { public build() {
return new Expression({ return new Expression(this.position, this.evaluator, this.parameters);
position: this.position,
evaluator: this.evaluator,
parameters: this.parameters,
});
} }
private evaluator: ExpressionEvaluator = () => `[${ExpressionBuilder.name}] evaluated-expression`; private evaluator: ExpressionEvaluator = () => `[${ExpressionBuilder.name}] evaluated-expression`;

View File

@@ -1,21 +1,22 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createPositionFromRegexFullMatch } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory'; import { createPositionFromRegexFullMatch } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory';
import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition'; import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition';
import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests';
describe('ExpressionPositionFactory', () => { describe('ExpressionPositionFactory', () => {
describe('createPositionFromRegexFullMatch', () => { describe('createPositionFromRegexFullMatch', () => {
describe('it is a transient factory', () => { it(`creates ${ExpressionPosition.name} instance`, () => {
// arrange // arrange
const fakeMatch = createRegexMatch(); const expectedType = ExpressionPosition;
const fakeMatch = createRegexMatch({
fullMatch: 'matched string',
matchIndex: 5,
});
// act // act
const create = () => createPositionFromRegexFullMatch(fakeMatch); const position = createPositionFromRegexFullMatch(fakeMatch);
// assert // assert
itIsTransientFactory({ expect(position).to.be.instanceOf(expectedType);
getter: create,
expectedType: ExpressionPosition,
});
}); });
it('creates a position with the correct start position', () => { it('creates a position with the correct start position', () => {
// arrange // arrange
const expectedStartPosition = 5; const expectedStartPosition = 5;
@@ -62,8 +63,10 @@ describe('ExpressionPositionFactory', () => {
describe('invalid values', () => { describe('invalid values', () => {
it('throws an error if match.index is undefined', () => { it('throws an error if match.index is undefined', () => {
// arrange // arrange
const fakeMatch = createRegexMatch(); const fakeMatch = createRegexMatch({
fakeMatch.index = undefined; fullMatch: 'matched string',
matchIndex: undefined,
});
const expectedError = `Regex match did not yield any results: ${JSON.stringify(fakeMatch)}`; const expectedError = `Regex match did not yield any results: ${JSON.stringify(fakeMatch)}`;
// act // act
const act = () => createPositionFromRegexFullMatch(fakeMatch); const act = () => createPositionFromRegexFullMatch(fakeMatch);
@@ -91,9 +94,9 @@ function createRegexMatch(options?: {
readonly capturingGroups?: readonly string[], readonly capturingGroups?: readonly string[],
readonly matchIndex?: number, readonly matchIndex?: number,
}): RegExpMatchArray { }): RegExpMatchArray {
const fullMatch = options?.fullMatch ?? 'default fake match'; const fullMatch = options?.fullMatch ?? 'fake match';
const capturingGroups = options?.capturingGroups ?? []; const capturingGroups = options?.capturingGroups ?? [];
const fakeMatch: RegExpMatchArray = [fullMatch, ...capturingGroups]; const fakeMatch: RegExpMatchArray = [fullMatch, ...capturingGroups];
fakeMatch.index = options?.matchIndex ?? 0; fakeMatch.index = options?.matchIndex;
return fakeMatch; return fakeMatch;
} }

View File

@@ -1,438 +1,168 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { import type { ExpressionEvaluator } from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression';
ExpressionEvaluator, ExpressionInitParameters, import { type IPrimitiveExpression, RegexParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser';
} from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression';
import {
type PrimitiveExpression, RegexParser, type ExpressionFactory, type RegexParserUtilities,
} from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser';
import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition'; import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester';
import { ExpressionStub } from '@tests/unit/shared/Stubs/ExpressionStub';
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
import type { IExpression } from '@/application/Parser/Script/Compiler/Expressions/Expression/IExpression';
import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub'; import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub';
import type { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/IFunctionParameterCollection'; import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import type { ExpressionPositionFactory } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
import type { FunctionParameterCollectionFactory } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
describe('RegexParser', () => { describe('RegexParser', () => {
describe('findExpressions', () => { describe('findExpressions', () => {
describe('error handling', () => {
describe('throws when code is absent', () => { describe('throws when code is absent', () => {
itEachAbsentStringValue((absentValue) => { itEachAbsentStringValue((absentValue) => {
// arrange // arrange
const expectedError = 'missing code'; const expectedError = 'missing code';
const sut = new RegexParserConcrete({ const sut = new RegexParserConcrete(/unimportant/);
regex: /unimportant/,
});
// act // act
const act = () => sut.findExpressions(absentValue); const act = () => sut.findExpressions(absentValue);
// assert // assert
const errorMessage = collectExceptionMessage(act); expect(act).to.throw(expectedError);
expect(errorMessage).to.include(expectedError);
}, { excludeNull: true, excludeUndefined: true }); }, { excludeNull: true, excludeUndefined: true });
}); });
describe('rethrows regex match errors', () => { it('throws when position is invalid', () => {
// arrange // arrange
const expectedMatchError = new TypeError('String.prototype.matchAll called with a non-global RegExp argument'); const regexMatchingEmpty = /^/gm; /* expressions cannot be empty */
const expectedMessage = 'Failed to match regex.'; const code = 'unimportant';
const expectedCodeInMessage = 'unimportant code content'; const expectedErrorParts = [
const expectedRegexInMessage = /failing-regex-because-it-is-non-global/; `[${RegexParserConcrete.constructor.name}]`,
const expectedErrorMessage = buildRethrowErrorMessage({ 'invalid script position',
message: expectedMessage, `Regex: ${regexMatchingEmpty}`,
code: expectedCodeInMessage, `Code: ${code}`,
regex: expectedRegexInMessage, ];
}); const sut = new RegexParserConcrete(regexMatchingEmpty);
itThrowsContextualError({
// act // act
throwingAction: (wrapError) => { let errorMessage: string | undefined;
const sut = new RegexParserConcrete( try {
{ sut.findExpressions(code);
regex: expectedRegexInMessage, } catch (err) {
utilities: { errorMessage = err.message;
wrapError, }
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert // assert
expectedContextMessage: expectedErrorMessage, expectExists(errorMessage);
expectedWrappedError: expectedMatchError, const error = errorMessage; // workaround for ts(18048): possibly 'undefined'
}); expect(
}); expectedErrorParts.every((part) => error.includes(part)),
describe('rethrows expression building errors', () => { `Expected parts: ${expectedErrorParts.join(', ')}`
// arrange + `Actual error: ${errorMessage}`,
const expectedMessage = 'Failed to build expression.';
const expectedInnerError = new Error('Expected error from building expression');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingExpressionBuilder = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
builder: throwingExpressionBuilder,
utilities: {
wrapError,
},
},
); );
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
}); });
}); describe('matches regex as expected', () => {
describe('rethrows position creation errors', () => {
// arrange // arrange
const expectedMessage = 'Failed to create position.'; const testCases = [
const expectedInnerError = new Error('Expected error from position factory');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingPositionFactory = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{ {
regex: expectedRegexInMessage, name: 'returns no result when regex does not match',
utilities: {
createPosition: throwingPositionFactory,
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
describe('rethrows parameter creation errors', () => {
// arrange
const expectedMessage = 'Failed to create parameters.';
const expectedInnerError = new Error('Expected error from parameter collection factory');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingParameterCollectionFactory = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
utilities: {
createParameterCollection: throwingParameterCollectionFactory,
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
describe('rethrows expression creation errors', () => {
// arrange
const expectedMessage = 'Failed to create expression.';
const expectedInnerError = new Error('Expected error from expression factory');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingExpressionFactory = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
utilities: {
createExpression: throwingExpressionFactory,
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
});
describe('handles matched regex correctly', () => {
// arrange
const testScenarios: readonly {
readonly description: string;
readonly regex: RegExp;
readonly code: string;
}[] = [
{
description: 'non-matching regex',
regex: /hello/g, regex: /hello/g,
code: 'world', code: 'world',
}, },
{ {
description: 'single regex match', name: 'returns expected when regex matches single',
regex: /hello/g, regex: /hello/g,
code: 'hello world', code: 'hello world',
}, },
{ {
description: 'multiple regex matches', name: 'returns expected when regex matches multiple',
regex: /l/g, regex: /l/g,
code: 'hello world', code: 'hello world',
}, },
]; ];
testScenarios.forEach(({ for (const testCase of testCases) {
description, code, regex, it(testCase.name, () => {
}) => { const expected = Array.from(testCase.code.matchAll(testCase.regex));
describe(description, () => {
it('generates expressions for all matches', () => {
// arrange
const expectedTotalExpressions = Array.from(code.matchAll(regex)).length;
const sut = new RegexParserConcrete({
regex,
});
// act
const expressions = sut.findExpressions(code);
// assert
const actualTotalExpressions = expressions.length;
expect(actualTotalExpressions).to.equal(
expectedTotalExpressions,
formatAssertionMessage([
`Expected ${actualTotalExpressions} expressions due to ${expectedTotalExpressions} matches`,
`Expressions:\n${indentText(JSON.stringify(expressions, undefined, 2))}`,
]),
);
});
it('builds primitive expressions for each match', () => {
const expected = Array.from(code.matchAll(regex));
const matches = new Array<RegExpMatchArray>(); const matches = new Array<RegExpMatchArray>();
const builder = (m: RegExpMatchArray): PrimitiveExpression => { const builder = (m: RegExpMatchArray): IPrimitiveExpression => {
matches.push(m); matches.push(m);
return createPrimitiveExpressionStub(); return mockPrimitiveExpression();
}; };
const sut = new RegexParserConcrete({ const sut = new RegexParserConcrete(testCase.regex, builder);
regex,
builder,
});
// act // act
sut.findExpressions(code); const expressions = sut.findExpressions(testCase.code);
// assert // assert
expect(expressions).to.have.lengthOf(matches.length);
expect(matches).to.deep.equal(expected); expect(matches).to.deep.equal(expected);
}); });
it('sets positions correctly from matches', () => { }
});
it('sets evaluator as expected', () => {
// arrange // arrange
const expectedMatches = [...code.matchAll(regex)]; const expected = getEvaluatorStub();
const { createExpression, getInitParameters } = createExpressionFactorySpy(); const regex = /hello/g;
const serializeRegexMatch = (match: RegExpMatchArray) => `[startPos:${match?.index ?? 'none'},length:${match?.[0]?.length ?? 'none'}]`; const code = 'hello';
const positionsForMatches = new Map<string, ExpressionPosition>(expectedMatches.map( const builder = (): IPrimitiveExpression => ({
(expectedMatch) => [serializeRegexMatch(expectedMatch), new ExpressionPosition(1, 4)], evaluator: expected,
));
const createPositionMock: ExpressionPositionFactory = (match) => {
const position = positionsForMatches.get(serializeRegexMatch(match));
return position ?? new ExpressionPosition(66, 666);
};
const sut = new RegexParserConcrete({
regex,
utilities: {
createExpression,
createPosition: createPositionMock,
},
});
// act
const expressions = sut.findExpressions(code);
// assert
const expectedPositions = [...positionsForMatches.values()];
const actualPositions = expressions.map((e) => getInitParameters(e)?.position);
expect(actualPositions).to.deep.equal(expectedPositions, formatAssertionMessage([
'Actual positions do not match the expected positions.',
`Expected total positions: ${expectedPositions.length} (due to ${expectedMatches.length} regex matches)`,
`Actual total positions: ${actualPositions.length}`,
`Expected positions:\n${indentText(JSON.stringify(expectedPositions, undefined, 2))}`,
`Actual positions:\n${indentText(JSON.stringify(actualPositions, undefined, 2))}`,
]));
});
});
});
});
it('sets evaluator correctly from expression', () => {
// arrange
const { createExpression, getInitParameters } = createExpressionFactorySpy();
const expectedEvaluate = createEvaluatorStub();
const { code, regex } = createCodeAndRegexMatchingOnce();
const builder = (): PrimitiveExpression => ({
evaluator: expectedEvaluate,
});
const sut = new RegexParserConcrete({
regex,
builder,
utilities: {
createExpression,
},
}); });
const sut = new RegexParserConcrete(regex, builder);
// act // act
const expressions = sut.findExpressions(code); const expressions = sut.findExpressions(code);
// assert // assert
expect(expressions).to.have.lengthOf(1); expect(expressions).to.have.lengthOf(1);
const actualEvaluate = getInitParameters(expressions[0])?.evaluator; expect(expressions[0].evaluate === expected);
expect(actualEvaluate).to.equal(expectedEvaluate);
}); });
it('sets parameters correctly from expression', () => { it('sets parameters as expected', () => {
// arrange // arrange
const expectedParameters: IReadOnlyFunctionParameterCollection['all'] = [ const expected = [
new FunctionParameterStub().withName('parameter1').withOptional(true), new FunctionParameterStub().withName('parameter1').withOptionality(true),
new FunctionParameterStub().withName('parameter2').withOptional(false), new FunctionParameterStub().withName('parameter2').withOptionality(false),
]; ];
const regex = /hello/g; const regex = /hello/g;
const code = 'hello'; const code = 'hello';
const builder = (): PrimitiveExpression => ({ const builder = (): IPrimitiveExpression => ({
evaluator: createEvaluatorStub(), evaluator: getEvaluatorStub(),
parameters: expectedParameters, parameters: expected,
});
const parameterCollection = new FunctionParameterCollectionStub();
const parameterCollectionFactoryStub
: FunctionParameterCollectionFactory = () => parameterCollection;
const { createExpression, getInitParameters } = createExpressionFactorySpy();
const sut = new RegexParserConcrete({
regex,
builder,
utilities: {
createExpression,
createParameterCollection: parameterCollectionFactoryStub,
},
}); });
const sut = new RegexParserConcrete(regex, builder);
// act // act
const expressions = sut.findExpressions(code); const expressions = sut.findExpressions(code);
// assert // assert
expect(expressions).to.have.lengthOf(1); expect(expressions).to.have.lengthOf(1);
const actualParameters = getInitParameters(expressions[0])?.parameters; expect(expressions[0].parameters.all).to.deep.equal(expected);
expect(actualParameters).to.equal(parameterCollection); });
expect(actualParameters?.all).to.deep.equal(expectedParameters); it('sets expected position', () => {
// arrange
const code = 'mate date in state is fate';
const regex = /ate/g;
const expected = [
new ExpressionPosition(1, 4),
new ExpressionPosition(6, 9),
new ExpressionPosition(15, 18),
new ExpressionPosition(23, 26),
];
const sut = new RegexParserConcrete(regex);
// act
const expressions = sut.findExpressions(code);
// assert
const actual = expressions.map((e) => e.position);
expect(actual).to.deep.equal(expected);
}); });
}); });
}); });
function buildRethrowErrorMessage( function mockBuilder(): (match: RegExpMatchArray) => IPrimitiveExpression {
expectedContext: {
readonly message: string;
readonly regex: RegExp;
readonly code: string;
},
): string {
return [
expectedContext.message,
`Class name: ${RegexParserConcrete.name}`,
`Regex pattern used: ${expectedContext.regex}`,
`Code: ${expectedContext.code}`,
].join('\n');
}
function createExpressionFactorySpy() {
const createdExpressions = new Map<IExpression, ExpressionInitParameters>();
const createExpression: ExpressionFactory = (parameters) => {
const expression = new ExpressionStub();
createdExpressions.set(expression, parameters);
return expression;
};
return {
createExpression,
getInitParameters: (expression) => createdExpressions.get(expression),
};
}
function createBuilderStub(): (match: RegExpMatchArray) => PrimitiveExpression {
return () => ({ return () => ({
evaluator: createEvaluatorStub(), evaluator: getEvaluatorStub(),
}); });
} }
function createEvaluatorStub(): ExpressionEvaluator { function getEvaluatorStub(): ExpressionEvaluator {
return () => `[${createEvaluatorStub.name}] evaluated code`; return () => `[${getEvaluatorStub.name}] evaluated code`;
} }
function createPrimitiveExpressionStub(): PrimitiveExpression { function mockPrimitiveExpression(): IPrimitiveExpression {
return { return {
evaluator: createEvaluatorStub(), evaluator: getEvaluatorStub(),
}; };
} }
function createCodeAndRegexMatchingOnce() {
const code = 'expected code in context';
const regex = /code/g;
return { code, regex };
}
class RegexParserConcrete extends RegexParser { class RegexParserConcrete extends RegexParser {
private readonly builder: RegexParser['buildExpression'];
protected regex: RegExp; protected regex: RegExp;
public constructor(parameters?: { public constructor(
regex?: RegExp, regex: RegExp,
builder?: RegexParser['buildExpression'], private readonly builder = mockBuilder(),
utilities?: Partial<RegexParserUtilities>, ) {
}) { super();
super({ this.regex = regex;
wrapError: parameters?.utilities?.wrapError
?? (() => new Error(`[${RegexParserConcrete}] wrapped error`)),
createPosition: parameters?.utilities?.createPosition
?? (() => new ExpressionPosition(0, 5)),
createExpression: parameters?.utilities?.createExpression
?? (() => new ExpressionStub()),
createParameterCollection: parameters?.utilities?.createParameterCollection
?? (() => new FunctionParameterCollectionStub()),
});
this.builder = parameters?.builder ?? createBuilderStub();
this.regex = parameters?.regex ?? /unimportant/g;
} }
protected buildExpression(match: RegExpMatchArray): PrimitiveExpression { protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
return this.builder(match); return this.builder(match);
} }
} }

View File

@@ -17,7 +17,7 @@ describe('FunctionCallArgument', () => {
itEachAbsentStringValue((absentValue) => { itEachAbsentStringValue((absentValue) => {
// arrange // arrange
const parameterName = 'paramName'; const parameterName = 'paramName';
const expectedError = `Missing argument value for the parameter "${parameterName}".`; const expectedError = `missing argument value for "${parameterName}"`;
const argumentValue = absentValue; const argumentValue = absentValue;
// act // act
const act = () => new FunctionCallArgumentBuilder() const act = () => new FunctionCallArgumentBuilder()

View File

@@ -1,7 +1,7 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { FunctionCallSequenceCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallSequenceCompiler'; import { FunctionCallSequenceCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallSequenceCompiler';
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
import type { SingleCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/SingleCallCompiler'; import type { SingleCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/SingleCallCompiler';
import type { CodeSegmentMerger } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CodeSegmentJoin/CodeSegmentMerger'; import type { CodeSegmentMerger } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CodeSegmentJoin/CodeSegmentMerger';
@@ -17,7 +17,7 @@ import { expectExists } from '@tests/shared/Assertions/ExpectExists';
describe('FunctionCallSequenceCompiler', () => { describe('FunctionCallSequenceCompiler', () => {
describe('instance', () => { describe('instance', () => {
itIsSingletonFactory({ itIsSingleton({
getter: () => FunctionCallSequenceCompiler.instance, getter: () => FunctionCallSequenceCompiler.instance,
expectedType: FunctionCallSequenceCompiler, expectedType: FunctionCallSequenceCompiler,
}); });

View File

@@ -9,9 +9,7 @@ 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/Script/Compiler/Function/Call/FunctionCall'; import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode'; import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester'; import { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
describe('NestedFunctionCallCompiler', () => { describe('NestedFunctionCallCompiler', () => {
describe('canCompile', () => { describe('canCompile', () => {
@@ -45,12 +43,12 @@ describe('NestedFunctionCallCompiler', () => {
// arrange // arrange
const argumentCompiler = new ArgumentCompilerStub(); const argumentCompiler = new ArgumentCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub(); const expectedContext = new FunctionCallCompilationContextStub();
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const compiler = new NestedFunctionCallCompilerBuilder() const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompiler) .withArgumentCompiler(argumentCompiler)
.build(); .build();
// act // act
compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext); compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
// assert // assert
const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall'); const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall');
expect(calls).have.lengthOf(1); expect(calls).have.lengthOf(1);
@@ -61,37 +59,33 @@ describe('NestedFunctionCallCompiler', () => {
// arrange // arrange
const argumentCompiler = new ArgumentCompilerStub(); const argumentCompiler = new ArgumentCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub(); const expectedContext = new FunctionCallCompilationContextStub();
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const expectedParentCall = callToFrontFunc;
const compiler = new NestedFunctionCallCompilerBuilder() const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompiler) .withArgumentCompiler(argumentCompiler)
.build(); .build();
// act // act
compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext); compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
// assert // assert
const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall'); const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall');
expect(calls).have.lengthOf(1); expect(calls).have.lengthOf(1);
const [,actualParentCall] = calls[0].args; const [,actualParentCall] = calls[0].args;
expect(actualParentCall).to.equal(expectedParentCall); expect(actualParentCall).to.equal(callToFrontFunc);
}); });
it('uses correct nested call', () => { it('uses correct nested call', () => {
// arrange // arrange
const argumentCompiler = new ArgumentCompilerStub(); const argumentCompiler = new ArgumentCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub(); const expectedContext = new FunctionCallCompilationContextStub();
const { const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
frontFunction, callToDeepFunc, callToFrontFunc,
} = createSingleFuncCallingAnotherFunc();
const expectedNestedCall = callToDeepFunc;
const compiler = new NestedFunctionCallCompilerBuilder() const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompiler) .withArgumentCompiler(argumentCompiler)
.build(); .build();
// act // act
compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext); compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
// assert // assert
const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall'); const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall');
expect(calls).have.lengthOf(1); expect(calls).have.lengthOf(1);
const [actualNestedCall] = calls[0].args; const [actualNestedCall] = calls[0].args;
expect(actualNestedCall).to.deep.equal(expectedNestedCall); expect(actualNestedCall).to.deep.equal(callToFrontFunc);
}); });
}); });
describe('re-compilation with compiled args', () => { describe('re-compilation with compiled args', () => {
@@ -100,11 +94,11 @@ describe('NestedFunctionCallCompiler', () => {
const singleCallCompilerStub = new SingleCallCompilerStub(); const singleCallCompilerStub = new SingleCallCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub() const expectedContext = new FunctionCallCompilationContextStub()
.withSingleCallCompiler(singleCallCompilerStub); .withSingleCallCompiler(singleCallCompilerStub);
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const compiler = new NestedFunctionCallCompilerBuilder() const compiler = new NestedFunctionCallCompilerBuilder()
.build(); .build();
// act // act
compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext); compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
// assert // assert
const calls = singleCallCompilerStub.callHistory.filter((call) => call.methodName === 'compileSingleCall'); const calls = singleCallCompilerStub.callHistory.filter((call) => call.methodName === 'compileSingleCall');
expect(calls).have.lengthOf(1); expect(calls).have.lengthOf(1);
@@ -119,12 +113,12 @@ describe('NestedFunctionCallCompiler', () => {
const singleCallCompilerStub = new SingleCallCompilerStub(); const singleCallCompilerStub = new SingleCallCompilerStub();
const context = new FunctionCallCompilationContextStub() const context = new FunctionCallCompilationContextStub()
.withSingleCallCompiler(singleCallCompilerStub); .withSingleCallCompiler(singleCallCompilerStub);
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const compiler = new NestedFunctionCallCompilerBuilder() const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompilerStub) .withArgumentCompiler(argumentCompilerStub)
.build(); .build();
// act // act
compiler.compileFunction(frontFunction, callToFrontFunc, context); compiler.compileFunction(frontFunc, callToFrontFunc, context);
// assert // assert
const calls = singleCallCompilerStub.callHistory.filter((call) => call.methodName === 'compileSingleCall'); const calls = singleCallCompilerStub.callHistory.filter((call) => call.methodName === 'compileSingleCall');
expect(calls).have.lengthOf(1); expect(calls).have.lengthOf(1);
@@ -146,9 +140,9 @@ describe('NestedFunctionCallCompiler', () => {
.withScenario({ givenNestedFunctionCall: callToDeepFunc1, result: callToDeepFunc1 }) .withScenario({ givenNestedFunctionCall: callToDeepFunc1, result: callToDeepFunc1 })
.withScenario({ givenNestedFunctionCall: callToDeepFunc2, result: callToDeepFunc2 }); .withScenario({ givenNestedFunctionCall: callToDeepFunc2, result: callToDeepFunc2 });
const expectedFlattenedCodes = [...singleCallCompilationScenario.values()].flat(); const expectedFlattenedCodes = [...singleCallCompilationScenario.values()].flat();
const frontFunction = createSharedFunctionStubWithCalls() const frontFunc = createSharedFunctionStubWithCalls()
.withCalls(callToDeepFunc1, callToDeepFunc2); .withCalls(callToDeepFunc1, callToDeepFunc2);
const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunction.name); const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunc.name);
const singleCallCompilerStub = new SingleCallCompilerStub() const singleCallCompilerStub = new SingleCallCompilerStub()
.withCallCompilationScenarios(singleCallCompilationScenario); .withCallCompilationScenarios(singleCallCompilationScenario);
const expectedContext = new FunctionCallCompilationContextStub() const expectedContext = new FunctionCallCompilationContextStub()
@@ -157,105 +151,73 @@ describe('NestedFunctionCallCompiler', () => {
.withArgumentCompiler(argumentCompiler) .withArgumentCompiler(argumentCompiler)
.build(); .build();
// act // act
const actualCodes = compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext); const actualCodes = compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
// assert // assert
expect(actualCodes).have.lengthOf(expectedFlattenedCodes.length); expect(actualCodes).have.lengthOf(expectedFlattenedCodes.length);
expect(actualCodes).to.have.members(expectedFlattenedCodes); expect(actualCodes).to.have.members(expectedFlattenedCodes);
}); });
describe('error handling', () => { describe('error handling', () => {
describe('rethrows error from argument compiler', () => { it('handles argument compiler errors', () => {
// arrange // arrange
const expectedInnerError = new Error(`Expected error from ${ArgumentCompilerStub.name}`); const argumentCompilerError = new Error('Test error');
const calleeFunctionName = 'expectedCalleeFunctionName';
const callerFunctionName = 'expectedCallerFunctionName';
const expectedErrorMessage = buildRethrowErrorMessage({
callee: calleeFunctionName,
caller: callerFunctionName,
});
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc({
frontFunctionName: callerFunctionName,
deepFunctionName: calleeFunctionName,
});
const argumentCompilerStub = new ArgumentCompilerStub(); const argumentCompilerStub = new ArgumentCompilerStub();
argumentCompilerStub.createCompiledNestedCall = () => { argumentCompilerStub.createCompiledNestedCall = () => {
throw expectedInnerError; throw argumentCompilerError;
}; };
const builder = new NestedFunctionCallCompilerBuilder() const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
.withArgumentCompiler(argumentCompilerStub); const expectedError = new AggregateError(
itThrowsContextualError({ [argumentCompilerError],
`Error with call to "${callToFrontFunc.functionName}" function from "${callToFrontFunc.functionName}" function`,
);
const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompilerStub)
.build();
// act // act
throwingAction: (wrapError) => { const act = () => compiler.compileFunction(
builder frontFunc,
.withErrorWrapper(wrapError)
.build()
.compileFunction(
frontFunction,
callToFrontFunc, callToFrontFunc,
new FunctionCallCompilationContextStub(), new FunctionCallCompilationContextStub(),
); );
},
// assert // assert
expectedWrappedError: expectedInnerError, expectDeepThrowsError(act, expectedError);
expectedContextMessage: expectedErrorMessage,
}); });
}); it('handles single call compiler errors', () => {
describe('rethrows error from single call compiler', () => {
// arrange // arrange
const expectedInnerError = new Error(`Expected error from ${SingleCallCompilerStub.name}`); const singleCallCompilerError = new Error('Test error');
const calleeFunctionName = 'expectedCalleeFunctionName';
const callerFunctionName = 'expectedCallerFunctionName';
const expectedErrorMessage = buildRethrowErrorMessage({
callee: calleeFunctionName,
caller: callerFunctionName,
});
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc({
frontFunctionName: callerFunctionName,
deepFunctionName: calleeFunctionName,
});
const singleCallCompiler = new SingleCallCompilerStub(); const singleCallCompiler = new SingleCallCompilerStub();
singleCallCompiler.compileSingleCall = () => { singleCallCompiler.compileSingleCall = () => {
throw expectedInnerError; throw singleCallCompilerError;
}; };
const context = new FunctionCallCompilationContextStub() const context = new FunctionCallCompilationContextStub()
.withSingleCallCompiler(singleCallCompiler); .withSingleCallCompiler(singleCallCompiler);
const builder = new NestedFunctionCallCompilerBuilder(); const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
itThrowsContextualError({ const expectedError = new AggregateError(
[singleCallCompilerError],
`Error with call to "${callToFrontFunc.functionName}" function from "${callToFrontFunc.functionName}" function`,
);
const compiler = new NestedFunctionCallCompilerBuilder()
.build();
// act // act
throwingAction: (wrapError) => { const act = () => compiler.compileFunction(
builder frontFunc,
.withErrorWrapper(wrapError)
.build()
.compileFunction(
frontFunction,
callToFrontFunc, callToFrontFunc,
context, context,
); );
},
// assert // assert
expectedWrappedError: expectedInnerError, expectDeepThrowsError(act, expectedError);
expectedContextMessage: expectedErrorMessage,
});
}); });
}); });
}); });
}); });
function createSingleFuncCallingAnotherFunc( function createSingleFuncCallingAnotherFunc() {
functionNames?: { const deepFunc = createSharedFunctionStubWithCode();
readonly frontFunctionName?: string; const callToDeepFunc = new FunctionCallStub().withFunctionName(deepFunc.name);
readonly deepFunctionName?: string; const frontFunc = createSharedFunctionStubWithCalls().withCalls(callToDeepFunc);
}, const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunc.name);
) {
const deepFunction = createSharedFunctionStubWithCode()
.withName(functionNames?.deepFunctionName ?? 'deep-function (is called by front-function)');
const callToDeepFunc = new FunctionCallStub().withFunctionName(deepFunction.name);
const frontFunction = createSharedFunctionStubWithCalls()
.withCalls(callToDeepFunc)
.withName(functionNames?.frontFunctionName ?? 'front-function (calls deep-function)');
const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunction.name);
return { return {
deepFunction, deepFunc,
frontFunction, frontFunc,
callToFrontFunc, callToFrontFunc,
callToDeepFunc, callToDeepFunc,
}; };
@@ -264,31 +226,14 @@ function createSingleFuncCallingAnotherFunc(
class NestedFunctionCallCompilerBuilder { class NestedFunctionCallCompilerBuilder {
private argumentCompiler: ArgumentCompiler = new ArgumentCompilerStub(); private argumentCompiler: ArgumentCompiler = new ArgumentCompilerStub();
private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub;
public withArgumentCompiler(argumentCompiler: ArgumentCompiler): this { public withArgumentCompiler(argumentCompiler: ArgumentCompiler): this {
this.argumentCompiler = argumentCompiler; this.argumentCompiler = argumentCompiler;
return this; return this;
} }
public withErrorWrapper(wrapError: ErrorWithContextWrapper): this {
this.wrapError = wrapError;
return this;
}
public build(): NestedFunctionCallCompiler { public build(): NestedFunctionCallCompiler {
return new NestedFunctionCallCompiler( return new NestedFunctionCallCompiler(
this.argumentCompiler, this.argumentCompiler,
this.wrapError,
); );
} }
} }
function buildRethrowErrorMessage(
functionNames: {
readonly caller: string;
readonly callee: string;
},
) {
return `Failed to call '${functionNames.callee}' (callee function) from '${functionNames.caller}' (caller function).`;
}

View File

@@ -11,7 +11,6 @@ import type { FunctionCallCompilationContext } from '@/application/Parser/Script
import { FunctionCallCompilationContextStub } from '@tests/unit/shared/Stubs/FunctionCallCompilationContextStub'; import { FunctionCallCompilationContextStub } from '@tests/unit/shared/Stubs/FunctionCallCompilationContextStub';
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub'; import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
import type { SingleCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/SingleCallCompiler'; import type { SingleCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/SingleCallCompiler';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
describe('AdaptiveFunctionCallCompiler', () => { describe('AdaptiveFunctionCallCompiler', () => {
describe('compileSingleCall', () => { describe('compileSingleCall', () => {
@@ -29,40 +28,40 @@ describe('AdaptiveFunctionCallCompiler', () => {
functionParameters: ['expected-parameter'], functionParameters: ['expected-parameter'],
callParameters: ['unexpected-parameter'], callParameters: ['unexpected-parameter'],
expectedError: expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter".` `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"`
+ '\nExpected parameter(s): "expected-parameter"', + '. Expected parameter(s): "expected-parameter"',
}, },
{ {
description: 'provided: multiple unexpected parameters, when: different one is expected', description: 'provided: multiple unexpected parameters, when: different one is expected',
functionParameters: ['expected-parameter'], functionParameters: ['expected-parameter'],
callParameters: ['unexpected-parameter1', 'unexpected-parameter2'], callParameters: ['unexpected-parameter1', 'unexpected-parameter2'],
expectedError: expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter1", "unexpected-parameter2".` `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter1", "unexpected-parameter2"`
+ '\nExpected parameter(s): "expected-parameter"', + '. Expected parameter(s): "expected-parameter"',
}, },
{ {
description: 'provided: an unexpected parameter, when: multiple parameters are expected', description: 'provided: an unexpected parameter, when: multiple parameters are expected',
functionParameters: ['expected-parameter1', 'expected-parameter2'], functionParameters: ['expected-parameter1', 'expected-parameter2'],
callParameters: ['expected-parameter1', 'expected-parameter2', 'unexpected-parameter'], callParameters: ['expected-parameter1', 'expected-parameter2', 'unexpected-parameter'],
expectedError: expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter".` `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"`
+ '\nExpected parameter(s): "expected-parameter1", "expected-parameter2"', + '. Expected parameter(s): "expected-parameter1", "expected-parameter2"',
}, },
{ {
description: 'provided: an unexpected parameter, when: none required', description: 'provided: an unexpected parameter, when: none required',
functionParameters: [], functionParameters: [],
callParameters: ['unexpected-call-parameter'], callParameters: ['unexpected-call-parameter'],
expectedError: expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-call-parameter".` `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-call-parameter"`
+ '\nExpected parameter(s): none', + '. Expected parameter(s): none',
}, },
{ {
description: 'provided: expected and unexpected parameter, when: one of them is expected', description: 'provided: expected and unexpected parameter, when: one of them is expected',
functionParameters: ['expected-parameter'], functionParameters: ['expected-parameter'],
callParameters: ['expected-parameter', 'unexpected-parameter'], callParameters: ['expected-parameter', 'unexpected-parameter'],
expectedError: expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter".` `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"`
+ '\nExpected parameter(s): "expected-parameter"', + '. Expected parameter(s): "expected-parameter"',
}, },
]; ];
testCases.forEach(({ testCases.forEach(({
@@ -89,8 +88,7 @@ describe('AdaptiveFunctionCallCompiler', () => {
// act // act
const act = () => builder.compileSingleCall(); const act = () => builder.compileSingleCall();
// assert // assert
const errorMessage = collectExceptionMessage(act); expect(act).to.throw(expectedError);
expect(errorMessage).to.include(expectedError);
}); });
}); });
}); });

View File

@@ -7,44 +7,38 @@ import type { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/
import { ExpressionsCompilerStub } from '@tests/unit/shared/Stubs/ExpressionsCompilerStub'; import { ExpressionsCompilerStub } from '@tests/unit/shared/Stubs/ExpressionsCompilerStub';
import { FunctionCallCompilationContextStub } from '@tests/unit/shared/Stubs/FunctionCallCompilationContextStub'; import { FunctionCallCompilationContextStub } from '@tests/unit/shared/Stubs/FunctionCallCompilationContextStub';
import { FunctionCallStub } from '@tests/unit/shared/Stubs/FunctionCallStub'; import { FunctionCallStub } from '@tests/unit/shared/Stubs/FunctionCallStub';
import { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError';
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub'; import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
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 type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
describe('NestedFunctionArgumentCompiler', () => { describe('NestedFunctionArgumentCompiler', () => {
describe('createCompiledNestedCall', () => { describe('createCompiledNestedCall', () => {
describe('rethrows error from expressions compiler', () => { it('should handle error from expressions compiler', () => {
// arrange // arrange
const expectedInnerError = new Error('child-');
const parameterName = 'parameterName'; const parameterName = 'parameterName';
const expectedErrorMessage = `Error when compiling argument for "${parameterName}"`;
const nestedCall = new FunctionCallStub() const nestedCall = new FunctionCallStub()
.withFunctionName('nested-function-call') .withFunctionName('nested-function-call')
.withArgumentCollection(new FunctionCallArgumentCollectionStub() .withArgumentCollection(new FunctionCallArgumentCollectionStub()
.withArgument(parameterName, 'unimportant-value')); .withArgument(parameterName, 'unimportant-value'));
const parentCall = new FunctionCallStub() const parentCall = new FunctionCallStub()
.withFunctionName('parent-function-call'); .withFunctionName('parent-function-call');
const expressionsCompilerError = new Error('child-');
const expectedError = new AggregateError(
[expressionsCompilerError],
`Error when compiling argument for "${parameterName}"`,
);
const expressionsCompiler = new ExpressionsCompilerStub(); const expressionsCompiler = new ExpressionsCompilerStub();
expressionsCompiler.compileExpressions = () => { throw expectedInnerError; }; expressionsCompiler.compileExpressions = () => { throw expressionsCompilerError; };
const builder = new NestedFunctionArgumentCompilerBuilder() const builder = new NestedFunctionArgumentCompilerBuilder()
.withParentFunctionCall(parentCall) .withParentFunctionCall(parentCall)
.withNestedFunctionCall(nestedCall) .withNestedFunctionCall(nestedCall)
.withExpressionsCompiler(expressionsCompiler); .withExpressionsCompiler(expressionsCompiler);
itThrowsContextualError({
// act // act
throwingAction: (wrapError) => { const act = () => builder.createCompiledNestedCall();
builder
.withErrorWrapper(wrapError)
.createCompiledNestedCall();
},
// assert // assert
expectedWrappedError: expectedInnerError, expectDeepThrowsError(act, expectedError);
expectedContextMessage: expectedErrorMessage,
});
}); });
describe('compilation', () => { describe('compilation', () => {
describe('without arguments', () => { describe('without arguments', () => {
@@ -264,8 +258,6 @@ class NestedFunctionArgumentCompilerBuilder implements ArgumentCompiler {
private context: FunctionCallCompilationContext = new FunctionCallCompilationContextStub(); private context: FunctionCallCompilationContext = new FunctionCallCompilationContextStub();
private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub;
public withExpressionsCompiler(expressionsCompiler: IExpressionsCompiler): this { public withExpressionsCompiler(expressionsCompiler: IExpressionsCompiler): this {
this.expressionsCompiler = expressionsCompiler; this.expressionsCompiler = expressionsCompiler;
return this; return this;
@@ -286,16 +278,8 @@ class NestedFunctionArgumentCompilerBuilder implements ArgumentCompiler {
return this; return this;
} }
public withErrorWrapper(wrapError: ErrorWithContextWrapper): this {
this.wrapError = wrapError;
return this;
}
public createCompiledNestedCall(): FunctionCall { public createCompiledNestedCall(): FunctionCall {
const compiler = new NestedFunctionArgumentCompiler( const compiler = new NestedFunctionArgumentCompiler(this.expressionsCompiler);
this.expressionsCompiler,
this.wrapError,
);
return compiler.createCompiledNestedCall( return compiler.createCompiledNestedCall(
this.nestedFunctionCall, this.nestedFunctionCall,
this.parentFunctionCall, this.parentFunctionCall,

View File

@@ -7,8 +7,8 @@ describe('FunctionParameterCollection', () => {
// arrange // arrange
const expected = [ const expected = [
new FunctionParameterStub().withName('1'), new FunctionParameterStub().withName('1'),
new FunctionParameterStub().withName('2').withOptional(true), new FunctionParameterStub().withName('2').withOptionality(true),
new FunctionParameterStub().withName('3').withOptional(false), new FunctionParameterStub().withName('3').withOptionality(false),
]; ];
const sut = new FunctionParameterCollection(); const sut = new FunctionParameterCollection();
for (const parameter of expected) { for (const parameter of expected) {

View File

@@ -1,23 +0,0 @@
import { describe, it, expect } from 'vitest';
import { FunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection';
import { createFunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests';
describe('FunctionParameterCollectionFactory', () => {
describe('createFunctionParameterCollection', () => {
describe('it is a transient factory', () => {
itIsTransientFactory({
getter: () => createFunctionParameterCollection(),
expectedType: FunctionParameterCollection,
});
});
it('returns an empty collection', () => {
// arrange
const expectedInitialParametersCount = 0;
// act
const collection = createFunctionParameterCollection();
// assert
expect(collection.all).to.have.lengthOf(expectedInitialParametersCount);
});
});
});

View File

@@ -35,7 +35,7 @@ describe('SharedFunctionCollection', () => {
it('throws if function does not exist', () => { it('throws if function does not exist', () => {
// arrange // arrange
const name = 'unique-name'; const name = 'unique-name';
const expectedError = `Called function is not defined: "${name}"`; const expectedError = `called function is not defined "${name}"`;
const func = createSharedFunctionStubWithCode() const func = createSharedFunctionStubWithCode()
.withName('unexpected-name'); .withName('unexpected-name');
const sut = new SharedFunctionCollection(); const sut = new SharedFunctionCollection();

View File

@@ -1,29 +1,25 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { FunctionData, CodeInstruction } from '@/application/collections/'; import type { FunctionData, CodeInstruction } from '@/application/collections/';
import type { ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction'; import type { ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
import { SharedFunctionsParser, type FunctionParameterFactory } from '@/application/Parser/Script/Compiler/Function/SharedFunctionsParser'; import { SharedFunctionsParser } from '@/application/Parser/Script/Compiler/Function/SharedFunctionsParser';
import { createFunctionDataWithCode, createFunctionDataWithoutCallOrCode } from '@tests/unit/shared/Stubs/FunctionDataStub'; import { createFunctionDataWithCode, createFunctionDataWithoutCallOrCode } from '@tests/unit/shared/Stubs/FunctionDataStub';
import { ParameterDefinitionDataStub } from '@tests/unit/shared/Stubs/ParameterDefinitionDataStub'; import { ParameterDefinitionDataStub } from '@tests/unit/shared/Stubs/ParameterDefinitionDataStub';
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub'; import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub';
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub'; import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub';
import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines'; import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester';
import type { FunctionParameterCollectionFactory } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
import { expectCallsFunctionBody, expectCodeFunctionBody } from './ExpectFunctionBodyType'; import { expectCallsFunctionBody, expectCodeFunctionBody } from './ExpectFunctionBodyType';
describe('SharedFunctionsParser', () => { describe('SharedFunctionsParser', () => {
describe('instance', () => { describe('instance', () => {
itIsSingletonFactory({ itIsSingleton({
getter: () => SharedFunctionsParser.instance, getter: () => SharedFunctionsParser.instance,
expectedType: SharedFunctionsParser, expectedType: SharedFunctionsParser,
}); });
@@ -131,7 +127,7 @@ describe('SharedFunctionsParser', () => {
}); });
}); });
describe('throws when parameters type is not as expected', () => { describe('throws when parameters type is not as expected', () => {
const testScenarios = [ const testCases = [
{ {
state: 'when not an array', state: 'when not an array',
invalidType: 5, invalidType: 5,
@@ -141,7 +137,7 @@ describe('SharedFunctionsParser', () => {
invalidType: ['a', { a: 'b' }], invalidType: ['a', { a: 'b' }],
}, },
]; ];
for (const testCase of testScenarios) { for (const testCase of testCases) {
it(testCase.state, () => { it(testCase.state, () => {
// arrange // arrange
const func = createFunctionDataWithCode() const func = createFunctionDataWithCode()
@@ -174,33 +170,25 @@ describe('SharedFunctionsParser', () => {
rules: expectedRules, rules: expectedRules,
}); });
}); });
describe('parameter creation', () => { it('rethrows including function name when FunctionParameter throws', () => {
describe('rethrows including function name when creating parameter throws', () => {
// arrange // arrange
const invalidParameterName = 'invalid-function-parameter-name'; const invalidParameterName = 'invalid function p@r4meter name';
const functionName = 'functionName'; const functionName = 'functionName';
const expectedErrorMessage = `Failed to create parameter: ${invalidParameterName} for function "${functionName}"`; const message = collectExceptionMessage(
const expectedInnerError = new Error('injected error'); () => new FunctionParameter(invalidParameterName, false),
const parameterFactory: FunctionParameterFactory = () => { );
throw expectedInnerError; const expectedError = `"${functionName}": ${message}`;
};
const functionData = createFunctionDataWithCode() const functionData = createFunctionDataWithCode()
.withName(functionName) .withName(functionName)
.withParameters(new ParameterDefinitionDataStub().withName(invalidParameterName)); .withParameters(new ParameterDefinitionDataStub().withName(invalidParameterName));
itThrowsContextualError({
// act // act
throwingAction: (wrapError) => { const act = () => new ParseFunctionsCallerWithDefaults()
new ParseFunctionsCallerWithDefaults()
.withFunctions([functionData]) .withFunctions([functionData])
.withFunctionParameterFactory(parameterFactory)
.withErrorWrapper(wrapError)
.parseFunctions(); .parseFunctions();
},
// assert // assert
expectedWrappedError: expectedInnerError, expect(act).to.throw(expectedError);
expectedContextMessage: expectedErrorMessage,
});
});
}); });
}); });
describe('given empty functions, returns empty collection', () => { describe('given empty functions, returns empty collection', () => {
@@ -294,18 +282,6 @@ class ParseFunctionsCallerWithDefaults {
private functions: readonly FunctionData[] = [createFunctionDataWithCode()]; private functions: readonly FunctionData[] = [createFunctionDataWithCode()];
private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub;
private parameterFactory: FunctionParameterFactory = (
name: string,
isOptional: boolean,
) => new FunctionParameterStub()
.withName(name)
.withOptional(isOptional);
private parameterCollectionFactory
: FunctionParameterCollectionFactory = () => new FunctionParameterCollectionStub();
public withSyntax(syntax: ILanguageSyntax) { public withSyntax(syntax: ILanguageSyntax) {
this.syntax = syntax; this.syntax = syntax;
return this; return this;
@@ -321,32 +297,8 @@ class ParseFunctionsCallerWithDefaults {
return this; return this;
} }
public withErrorWrapper(wrapError: ErrorWithContextWrapper): this {
this.wrapError = wrapError;
return this;
}
public withFunctionParameterFactory(parameterFactory: FunctionParameterFactory): this {
this.parameterFactory = parameterFactory;
return this;
}
public withParameterCollectionFactory(
parameterCollectionFactory: FunctionParameterCollectionFactory,
): this {
this.parameterCollectionFactory = parameterCollectionFactory;
return this;
}
public parseFunctions() { public parseFunctions() {
const sut = new SharedFunctionsParser( const sut = new SharedFunctionsParser(this.codeValidator);
{
codeValidator: this.codeValidator,
wrapError: this.wrapError,
createParameter: this.parameterFactory,
createParameterCollection: this.parameterCollectionFactory,
},
);
return sut.parseFunctions(this.functions, this.syntax); return sut.parseFunctions(this.functions, this.syntax);
} }
} }

View File

@@ -1,7 +1,9 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { FunctionData } from '@/application/collections/'; import type { FunctionData } from '@/application/collections/';
import { ScriptCode } from '@/domain/ScriptCode';
import { ScriptCompiler } from '@/application/Parser/Script/Compiler/ScriptCompiler'; import { ScriptCompiler } from '@/application/Parser/Script/Compiler/ScriptCompiler';
import type { ISharedFunctionsParser } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionsParser'; import type { ISharedFunctionsParser } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionsParser';
import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode';
import type { FunctionCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler'; import type { FunctionCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler';
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub'; import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub';
@@ -15,13 +17,8 @@ import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICod
import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub'; import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub';
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub'; import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub'; import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
import { createScriptCodeFactoryStub } from '@tests/unit/shared/Stubs/ScriptCodeFactoryStub';
import { itThrowsContextualError } from '../../ContextualErrorTester';
describe('ScriptCompiler', () => { describe('ScriptCompiler', () => {
describe('canCompile', () => { describe('canCompile', () => {
@@ -61,30 +58,11 @@ describe('ScriptCompiler', () => {
// assert // assert
expect(act).to.throw(expectedError); expect(act).to.throw(expectedError);
}); });
describe('code construction', () => { it('returns code as expected', () => {
it('returns code from the factory', () => {
// arrange // arrange
const expectedCode = new ScriptCodeStub(); const expected: CompiledCode = {
const scriptCodeFactory = () => expectedCode; code: 'expected-code',
const sut = new ScriptCompilerBuilder() revertCode: 'expected-revert-code',
.withSomeFunctions()
.withScriptCodeFactory(scriptCodeFactory)
.build();
// act
const actualCode = sut.compile(createScriptDataWithCall());
// assert
expect(actualCode).to.equal(expectedCode);
});
it('creates code correctly', () => {
// arrange
const expectedCode = 'expected-code';
const expectedRevertCode = 'expected-revert-code';
let actualCode: string | undefined;
let actualRevertCode: string | undefined;
const scriptCodeFactory = (code: string, revertCode: string) => {
actualCode = code;
actualRevertCode = revertCode;
return new ScriptCodeStub();
}; };
const call = new FunctionCallDataStub(); const call = new FunctionCallDataStub();
const script = createScriptDataWithCall(call); const script = createScriptDataWithCall(call);
@@ -93,27 +71,18 @@ describe('ScriptCompiler', () => {
const functionParserMock = new SharedFunctionsParserStub(); const functionParserMock = new SharedFunctionsParserStub();
functionParserMock.setup(functions, compiledFunctions); functionParserMock.setup(functions, compiledFunctions);
const callCompilerMock = new FunctionCallCompilerStub(); const callCompilerMock = new FunctionCallCompilerStub();
callCompilerMock.setup( callCompilerMock.setup(parseFunctionCalls(call), compiledFunctions, expected);
parseFunctionCalls(call),
compiledFunctions,
new CompiledCodeStub()
.withCode(expectedCode)
.withRevertCode(expectedRevertCode),
);
const sut = new ScriptCompilerBuilder() const sut = new ScriptCompilerBuilder()
.withFunctions(...functions) .withFunctions(...functions)
.withSharedFunctionsParser(functionParserMock) .withSharedFunctionsParser(functionParserMock)
.withFunctionCallCompiler(callCompilerMock) .withFunctionCallCompiler(callCompilerMock)
.withScriptCodeFactory(scriptCodeFactory)
.build(); .build();
// act // act
sut.compile(script); const code = sut.compile(script);
// assert // assert
expect(actualCode).to.equal(expectedCode); expect(code.execute).to.equal(expected.code);
expect(actualRevertCode).to.equal(expectedRevertCode); expect(code.revert).to.equal(expected.revertCode);
}); });
});
describe('parses functions as expected', () => { describe('parses functions as expected', () => {
it('parses functions with expected syntax', () => { it('parses functions with expected syntax', () => {
// arrange // arrange
@@ -147,57 +116,49 @@ describe('ScriptCompiler', () => {
expect(parser.callHistory[0].functions).to.deep.equal(expectedFunctions); expect(parser.callHistory[0].functions).to.deep.equal(expectedFunctions);
}); });
}); });
describe('rethrows error with script name', () => { it('rethrows error with script name', () => {
// arrange // arrange
const scriptName = 'scriptName'; const scriptName = 'scriptName';
const expectedErrorMessage = `Failed to compile script: ${scriptName}`; const innerError = 'innerError';
const expectedInnerError = new Error(); const expectedError = `Script "${scriptName}" ${innerError}`;
const callCompiler: FunctionCallCompiler = { const callCompiler: FunctionCallCompiler = {
compileFunctionCalls: () => { throw expectedInnerError; }, compileFunctionCalls: () => { throw new Error(innerError); },
}; };
const scriptData = createScriptDataWithCall() const scriptData = createScriptDataWithCall()
.withName(scriptName); .withName(scriptName);
const builder = new ScriptCompilerBuilder() const sut = new ScriptCompilerBuilder()
.withSomeFunctions() .withSomeFunctions()
.withFunctionCallCompiler(callCompiler); .withFunctionCallCompiler(callCompiler)
itThrowsContextualError({ .build();
// act // act
throwingAction: (wrapError) => { const act = () => sut.compile(scriptData);
builder
.withErrorWrapper(wrapError)
.build()
.compile(scriptData);
},
// assert // assert
expectedWrappedError: expectedInnerError, expect(act).to.throw(expectedError);
expectedContextMessage: expectedErrorMessage,
}); });
}); it('rethrows error from ScriptCode with script name', () => {
describe('rethrows error from script code factory with script name', () => {
// arrange // arrange
const scriptName = 'scriptName'; const scriptName = 'scriptName';
const expectedErrorMessage = `Failed to compile script: ${scriptName}`; const syntax = new LanguageSyntaxStub();
const expectedInnerError = new Error(); const invalidCode = new CompiledCodeStub()
const scriptCodeFactory: ScriptCodeFactory = () => { .withCode('' /* invalid code (empty string) */);
throw expectedInnerError; const realExceptionMessage = collectExceptionMessage(
() => new ScriptCode(invalidCode.code, invalidCode.revertCode),
);
const expectedError = `Script "${scriptName}" ${realExceptionMessage}`;
const callCompiler: FunctionCallCompiler = {
compileFunctionCalls: () => invalidCode,
}; };
const scriptData = createScriptDataWithCall() const scriptData = createScriptDataWithCall()
.withName(scriptName); .withName(scriptName);
const builder = new ScriptCompilerBuilder() const sut = new ScriptCompilerBuilder()
.withSomeFunctions() .withSomeFunctions()
.withScriptCodeFactory(scriptCodeFactory); .withFunctionCallCompiler(callCompiler)
itThrowsContextualError({ .withSyntax(syntax)
.build();
// act // act
throwingAction: (wrapError) => { const act = () => sut.compile(scriptData);
builder
.withErrorWrapper(wrapError)
.build()
.compile(scriptData);
},
// assert // assert
expectedWrappedError: expectedInnerError, expect(act).to.throw(expectedError);
expectedContextMessage: expectedErrorMessage,
});
}); });
it('validates compiled code as expected', () => { it('validates compiled code as expected', () => {
// arrange // arrange
@@ -205,27 +166,17 @@ describe('ScriptCompiler', () => {
NoEmptyLines, NoEmptyLines,
// Allow duplicated lines to enable calling same function multiple times // Allow duplicated lines to enable calling same function multiple times
]; ];
const expectedExecuteCode = 'execute code to be validated';
const expectedRevertCode = 'revert code to be validated';
const scriptData = createScriptDataWithCall(); const scriptData = createScriptDataWithCall();
const validator = new CodeValidatorStub(); const validator = new CodeValidatorStub();
const sut = new ScriptCompilerBuilder() const sut = new ScriptCompilerBuilder()
.withSomeFunctions() .withSomeFunctions()
.withCodeValidator(validator) .withCodeValidator(validator)
.withFunctionCallCompiler(
new FunctionCallCompilerStub()
.withDefaultCompiledCode(
new CompiledCodeStub()
.withCode(expectedExecuteCode)
.withRevertCode(expectedRevertCode),
),
)
.build(); .build();
// act // act
sut.compile(scriptData); const compilationResult = sut.compile(scriptData);
// assert // assert
validator.assertHistory({ validator.assertHistory({
validatedCodes: [expectedExecuteCode, expectedRevertCode], validatedCodes: [compilationResult.execute, compilationResult.revert],
rules: expectedRules, rules: expectedRules,
}); });
}); });
@@ -249,12 +200,6 @@ class ScriptCompilerBuilder {
private codeValidator: ICodeValidator = new CodeValidatorStub(); private codeValidator: ICodeValidator = new CodeValidatorStub();
private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub;
private scriptCodeFactory: ScriptCodeFactory = createScriptCodeFactoryStub({
defaultCodePrefix: ScriptCompilerBuilder.name,
});
public withFunctions(...functions: FunctionData[]): this { public withFunctions(...functions: FunctionData[]): this {
this.functions = functions; this.functions = functions;
return this; return this;
@@ -299,16 +244,6 @@ class ScriptCompilerBuilder {
return this; return this;
} }
public withErrorWrapper(wrapError: ErrorWithContextWrapper): this {
this.wrapError = wrapError;
return this;
}
public withScriptCodeFactory(scriptCodeFactory: ScriptCodeFactory): this {
this.scriptCodeFactory = scriptCodeFactory;
return this;
}
public build(): ScriptCompiler { public build(): ScriptCompiler {
if (!this.functions) { if (!this.functions) {
throw new Error('Function behavior not defined'); throw new Error('Function behavior not defined');
@@ -319,8 +254,6 @@ class ScriptCompilerBuilder {
this.sharedFunctionsParser, this.sharedFunctionsParser,
this.callCompiler, this.callCompiler,
this.codeValidator, this.codeValidator,
this.wrapError,
this.scriptCodeFactory,
); );
} }
} }

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { ScriptData } from '@/application/collections/'; import type { ScriptData } from '@/application/collections/';
import { parseScript, type ScriptFactory } from '@/application/Parser/Script/ScriptParser'; import { parseScript, type ScriptFactoryType } from '@/application/Parser/Script/ScriptParser';
import { type DocsParser } from '@/application/Parser/DocumentationParser'; import { parseDocs } from '@/application/Parser/DocumentationParser';
import { RecommendationLevel } from '@/domain/RecommendationLevel'; import { RecommendationLevel } from '@/domain/RecommendationLevel';
import type { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext'; import type { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
@@ -11,88 +11,54 @@ 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 { CategoryCollectionParseContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionParseContextStub'; import { CategoryCollectionParseContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionParseContextStub';
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import { expectThrowsNodeError, type ITestScenario, NodeValidationTestRunner } from '@tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner';
import { Script } from '@/domain/Script'; import { Script } from '@/domain/Script';
import type { IEnumParser } from '@/application/Common/Enum'; import type { IEnumParser } from '@/application/Common/Enum';
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines'; import { NoDuplicatedLines } from '@/application/Parser/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/Script/Validation/ICodeValidator'; import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub';
import type { NodeDataValidatorFactory } from '@/application/Parser/NodeValidation/NodeDataValidator';
import { NodeDataValidatorStub, createNodeDataValidatorFactoryStub } from '@tests/unit/shared/Stubs/NodeDataValidatorStub';
import { NodeDataType } from '@/application/Parser/NodeValidation/NodeDataType';
import type { ScriptNodeErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext';
import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
import { createScriptCodeFactoryStub } from '@tests/unit/shared/Stubs/ScriptCodeFactoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { createScriptFactorySpy } from '@tests/unit/shared/Stubs/ScriptFactoryStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { itThrowsContextualError } from '../ContextualErrorTester';
import { itAsserts, itValidatesDefinedData, itValidatesName } from '../NodeDataValidationTester';
import { generateDataValidationTestScenarios } from '../DataValidationTestScenarioGenerator';
describe('ScriptParser', () => { describe('ScriptParser', () => {
describe('parseScript', () => { describe('parseScript', () => {
it('parses name correctly', () => { it('parses name as expected', () => {
// arrange // arrange
const expected = 'test-expected-name'; const expected = 'test-expected-name';
const scriptData = createScriptDataWithCode() const script = createScriptDataWithCode()
.withName(expected); .withName(expected);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act // act
const actualScript = new TestContext() const actual = new TestBuilder()
.withData(scriptData) .withData(script)
.withScriptFactory(scriptFactorySpy)
.parseScript(); .parseScript();
// assert // assert
const actualName = getInitParameters(actualScript)?.name; expect(actual.name).to.equal(expected);
expect(actualName).to.equal(expected);
}); });
it('parses docs correctly', () => { it('parses docs as expected', () => {
// arrange // arrange
const expectedDocs = ['https://expected-doc1.com', 'https://expected-doc2.com']; const docs = ['https://expected-doc1.com', 'https://expected-doc2.com'];
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy(); const script = createScriptDataWithCode()
const scriptData = createScriptDataWithCode() .withDocs(docs);
.withDocs(expectedDocs); const expected = parseDocs(script);
const docsParser: DocsParser = (data) => data.docs as typeof expectedDocs;
// act // act
const actualScript = new TestContext() const actual = new TestBuilder()
.withData(scriptData) .withData(script)
.withScriptFactory(scriptFactorySpy)
.withDocsParser(docsParser)
.parseScript(); .parseScript();
// assert // assert
const actualDocs = getInitParameters(actualScript)?.docs; expect(actual.docs).to.deep.equal(expected);
expect(actualDocs).to.deep.equal(expectedDocs);
});
it('gets script from the factory', () => {
// arrange
const expectedScript = new ScriptStub('expected-script');
const scriptFactory: ScriptFactory = () => expectedScript;
// act
const actualScript = new TestContext()
.withScriptFactory(scriptFactory)
.parseScript();
// assert
expect(actualScript).to.equal(expectedScript);
}); });
describe('level', () => { describe('level', () => {
describe('generated `undefined` level if given absent value', () => { describe('accepts absent level', () => {
itEachAbsentStringValue((absentValue) => { itEachAbsentStringValue((absentValue) => {
// arrange // arrange
const expectedLevel = undefined; const script = createScriptDataWithCode()
const scriptData = createScriptDataWithCode()
.withRecommend(absentValue); .withRecommend(absentValue);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act // act
const actualScript = new TestContext() const actual = new TestBuilder()
.withData(scriptData) .withData(script)
.withScriptFactory(scriptFactorySpy)
.parseScript(); .parseScript();
// assert // assert
const actualLevel = getInitParameters(actualScript)?.level; expect(actual.level).to.equal(undefined);
expect(actualLevel).to.equal(expectedLevel);
}, { excludeNull: true }); }, { excludeNull: true });
}); });
it('parses level as expected', () => { it('parses level as expected', () => {
@@ -100,94 +66,63 @@ describe('ScriptParser', () => {
const expectedLevel = RecommendationLevel.Standard; const expectedLevel = RecommendationLevel.Standard;
const expectedName = 'level'; const expectedName = 'level';
const levelText = 'standard'; const levelText = 'standard';
const scriptData = createScriptDataWithCode() const script = createScriptDataWithCode()
.withRecommend(levelText); .withRecommend(levelText);
const parserMock = new EnumParserStub<RecommendationLevel>() const parserMock = new EnumParserStub<RecommendationLevel>()
.setup(expectedName, levelText, expectedLevel); .setup(expectedName, levelText, expectedLevel);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act // act
const actualScript = new TestContext() const actual = new TestBuilder()
.withData(scriptData) .withData(script)
.withParser(parserMock) .withParser(parserMock)
.withScriptFactory(scriptFactorySpy)
.parseScript(); .parseScript();
// assert // assert
const actualLevel = getInitParameters(actualScript)?.level; expect(actual.level).to.equal(expectedLevel);
expect(actualLevel).to.equal(expectedLevel);
}); });
}); });
describe('code', () => { describe('code', () => {
it('creates from script code factory', () => {
// arrange
const expectedCode = new ScriptCodeStub();
const scriptCodeFactory: ScriptCodeFactory = () => expectedCode;
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act
const actualScript = new TestContext()
.withScriptCodeFactory(scriptCodeFactory)
.withScriptFactory(scriptFactorySpy)
.parseScript();
// assert
const actualCode = getInitParameters(actualScript)?.code;
expect(expectedCode).to.equal(actualCode);
});
describe('parses code correctly', () => {
it('parses "execute" as expected', () => { it('parses "execute" as expected', () => {
// arrange // arrange
const expectedCode = 'expected-code'; const expected = 'expected-code';
let actualCode: string | undefined; const script = createScriptDataWithCode()
const scriptCodeFactory: ScriptCodeFactory = (code) => { .withCode(expected);
actualCode = code;
return new ScriptCodeStub();
};
const scriptData = createScriptDataWithCode()
.withCode(expectedCode);
// act // act
new TestContext() const parsed = new TestBuilder()
.withData(scriptData) .withData(script)
.withScriptCodeFactory(scriptCodeFactory)
.parseScript(); .parseScript();
// assert // assert
expect(actualCode).to.equal(expectedCode); const actual = parsed.code.execute;
expect(actual).to.equal(expected);
}); });
it('parses "revert" as expected', () => { it('parses "revert" as expected', () => {
// arrange // arrange
const expectedRevertCode = 'expected-revert-code'; const expected = 'expected-revert-code';
const scriptData = createScriptDataWithCode() const script = createScriptDataWithCode()
.withRevertCode(expectedRevertCode); .withRevertCode(expected);
let actualRevertCode: string | undefined;
const scriptCodeFactory: ScriptCodeFactory = (_, revertCode) => {
actualRevertCode = revertCode;
return new ScriptCodeStub();
};
// act // act
new TestContext() const parsed = new TestBuilder()
.withData(scriptData) .withData(script)
.withScriptCodeFactory(scriptCodeFactory)
.parseScript(); .parseScript();
// assert // assert
expect(actualRevertCode).to.equal(expectedRevertCode); const actual = parsed.code.revert;
}); expect(actual).to.equal(expected);
}); });
describe('compiler', () => { describe('compiler', () => {
it('compiles the code through the compiler', () => { it('gets code from compiler', () => {
// arrange // arrange
const expectedCode = new ScriptCodeStub(); const expected = new ScriptCodeStub();
const script = createScriptDataWithCode(); const script = createScriptDataWithCode();
const compiler = new ScriptCompilerStub() const compiler = new ScriptCompilerStub()
.withCompileAbility(script, expectedCode); .withCompileAbility(script, expected);
const parseContext = new CategoryCollectionParseContextStub() const parseContext = new CategoryCollectionParseContextStub()
.withCompiler(compiler); .withCompiler(compiler);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act // act
const actualScript = new TestContext() const parsed = new TestBuilder()
.withData(script) .withData(script)
.withContext(parseContext) .withContext(parseContext)
.withScriptFactory(scriptFactorySpy)
.parseScript(); .parseScript();
// assert // assert
const actualCode = getInitParameters(actualScript)?.code; const actual = parsed.code;
expect(actualCode).to.equal(expectedCode); expect(actual).to.equal(expected);
}); });
}); });
describe('syntax', () => { describe('syntax', () => {
@@ -200,7 +135,7 @@ describe('ScriptParser', () => {
const script = createScriptDataWithoutCallOrCodes() const script = createScriptDataWithoutCallOrCodes()
.withCode(duplicatedCode); .withCode(duplicatedCode);
// act // act
const act = () => new TestContext() const act = () => new TestBuilder()
.withData(script) .withData(script)
.withContext(parseContext); .withContext(parseContext);
// assert // assert
@@ -214,26 +149,18 @@ describe('ScriptParser', () => {
NoEmptyLines, NoEmptyLines,
NoDuplicatedLines, NoDuplicatedLines,
]; ];
const expectedCode = 'expected code to be validated';
const expectedRevertCode = 'expected revert code to be validated';
const expectedCodeCalls = [
expectedCode,
expectedRevertCode,
];
const validator = new CodeValidatorStub(); const validator = new CodeValidatorStub();
const scriptCodeFactory = createScriptCodeFactoryStub({ const script = createScriptDataWithCode()
scriptCode: new ScriptCodeStub() .withCode('expected code to be validated')
.withExecute(expectedCode) .withRevertCode('expected revert code to be validated');
.withRevert(expectedRevertCode),
});
// act // act
new TestContext() new TestBuilder()
.withScriptCodeFactory(scriptCodeFactory) .withData(script)
.withCodeValidator(validator) .withCodeValidator(validator)
.parseScript(); .parseScript();
// assert // assert
validator.assertHistory({ validator.assertHistory({
validatedCodes: expectedCodeCalls, validatedCodes: [script.code, script.revertCode],
rules: expectedRules, rules: expectedRules,
}); });
}); });
@@ -248,7 +175,7 @@ describe('ScriptParser', () => {
const parseContext = new CategoryCollectionParseContextStub() const parseContext = new CategoryCollectionParseContextStub()
.withCompiler(compiler); .withCompiler(compiler);
// act // act
new TestContext() new TestBuilder()
.withData(script) .withData(script)
.withCodeValidator(validator) .withCodeValidator(validator)
.withContext(parseContext) .withContext(parseContext)
@@ -261,250 +188,111 @@ describe('ScriptParser', () => {
}); });
}); });
}); });
describe('validation', () => { describe('invalid script data', () => {
describe('validates for name', () => { describe('validates script data', () => {
// arrange // arrange
const expectedName = 'expected script name to be validated'; const createTest = (script: ScriptData): ITestScenario => ({
const script = createScriptDataWithCall() act: () => new TestBuilder()
.withName(expectedName);
const expectedContext: ScriptNodeErrorContext = {
type: NodeDataType.Script,
selfNode: script,
};
itValidatesName((validatorFactory) => {
// act
new TestContext()
.withData(script) .withData(script)
.withValidatorFactory(validatorFactory) .parseScript(),
.parseScript(); expectedContext: {
// assert type: NodeType.Script,
return { selfNode: script,
expectedNameToValidate: expectedName,
expectedErrorContext: expectedContext,
};
});
});
describe('validates for defined data', () => {
// arrange
const expectedScript = createScriptDataWithCall();
const expectedContext: ScriptNodeErrorContext = {
type: NodeDataType.Script,
selfNode: expectedScript,
};
itValidatesDefinedData(
(validatorFactory) => {
// act
new TestContext()
.withData(expectedScript)
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
return {
expectedDataToValidate: expectedScript,
expectedErrorContext: expectedContext,
};
}, },
});
// act and assert
new NodeValidationTestRunner()
.testInvalidNodeName((invalidName) => {
return createTest(
createScriptDataWithCall().withName(invalidName),
); );
})
.testMissingNodeData((node) => {
return createTest(node as ScriptData);
})
.runThrowingCase({
name: 'throws when both function call and code are defined',
scenario: createTest(
createScriptDataWithCall().withCode('code'),
),
expectedMessage: 'Both "call" and "code" are defined.',
})
.runThrowingCase({
name: 'throws when both function call and revertCode are defined',
scenario: createTest(
createScriptDataWithCall().withRevertCode('revert-code'),
),
expectedMessage: 'Both "call" and "revertCode" are defined.',
})
.runThrowingCase({
name: 'throws when neither call or revertCode are defined',
scenario: createTest(
createScriptDataWithoutCallOrCodes(),
),
expectedMessage: 'Neither "call" or "code" is defined.',
}); });
describe('validates data', () => { });
it(`rethrows exception if ${Script.name} cannot be constructed`, () => {
// arrange // arrange
const testScenarios = generateDataValidationTestScenarios<ScriptData>( const expectedError = 'script creation failed';
{ const factoryMock: ScriptFactoryType = () => { throw new Error(expectedError); };
assertErrorMessage: 'Neither "call" or "code" is defined.', const data = createScriptDataWithCode();
expectFail: [{
description: 'with no call or code',
data: createScriptDataWithoutCallOrCodes(),
}],
expectPass: [
{
description: 'with call',
data: createScriptDataWithCall(),
},
{
description: 'with code',
data: createScriptDataWithCode(),
},
],
},
{
assertErrorMessage: 'Both "call" and "revertCode" are defined.',
expectFail: [{
description: 'with both call and revertCode',
data: createScriptDataWithCall()
.withRevertCode('revert-code'),
}],
expectPass: [
{
description: 'with call, without revertCode',
data: createScriptDataWithCall()
.withRevertCode(undefined),
},
{
description: 'with revertCode, without call',
data: createScriptDataWithCode()
.withRevertCode('revert code'),
},
],
},
{
assertErrorMessage: 'Both "call" and "code" are defined.',
expectFail: [{
description: 'with both call and code',
data: createScriptDataWithCall()
.withCode('code'),
}],
expectPass: [
{
description: 'with call, without code',
data: createScriptDataWithCall()
.withCode(''),
},
{
description: 'with code, without call',
data: createScriptDataWithCode()
.withCode('code'),
},
],
},
);
testScenarios.forEach(({
description, expectedPass, data: scriptData, expectedMessage,
}) => {
describe(description, () => {
itAsserts({
expectedConditionResult: expectedPass,
test: (validatorFactory) => {
const expectedContext: ScriptNodeErrorContext = {
type: NodeDataType.Script,
selfNode: scriptData,
};
// act // act
new TestContext() const act = () => new TestBuilder()
.withData(scriptData) .withData(data)
.withValidatorFactory(validatorFactory) .withFactory(factoryMock)
.parseScript(); .parseScript();
// assert // expect
expectExists(expectedMessage); expectThrowsNodeError({
return { act,
expectedErrorMessage: expectedMessage, expectedContext: {
expectedErrorContext: expectedContext, type: NodeType.Script,
}; selfNode: data,
}, },
}); }, expectedError);
});
});
});
});
describe('rethrows exception if script factory fails', () => {
// arrange
const givenData = createScriptDataWithCode();
const expectedContextMessage = 'Failed to parse script.';
const expectedError = new Error();
const validatorFactory: NodeDataValidatorFactory = () => {
const validatorStub = new NodeDataValidatorStub();
validatorStub.createContextualErrorMessage = (message) => message;
return validatorStub;
};
// act & assert
itThrowsContextualError({
throwingAction: (wrapError) => {
const factoryMock: ScriptFactory = () => {
throw expectedError;
};
new TestContext()
.withScriptFactory(factoryMock)
.withErrorWrapper(wrapError)
.withValidatorFactory(validatorFactory)
.withData(givenData)
.parseScript();
},
expectedWrappedError: expectedError,
expectedContextMessage,
}); });
}); });
}); });
}); });
class TestContext { class TestBuilder {
private data: ScriptData = createScriptDataWithCode(); private data: ScriptData = createScriptDataWithCode();
private context: ICategoryCollectionParseContext = new CategoryCollectionParseContextStub(); private context: ICategoryCollectionParseContext = new CategoryCollectionParseContextStub();
private levelParser: IEnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>() private parser: IEnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>()
.setupDefaultValue(RecommendationLevel.Standard); .setupDefaultValue(RecommendationLevel.Standard);
private scriptFactory: ScriptFactory = createScriptFactorySpy().scriptFactorySpy; private factory?: ScriptFactoryType = undefined;
private codeValidator: ICodeValidator = new CodeValidatorStub(); private codeValidator: ICodeValidator = new CodeValidatorStub();
private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get(); public withCodeValidator(codeValidator: ICodeValidator) {
private validatorFactory: NodeDataValidatorFactory = createNodeDataValidatorFactoryStub;
private docsParser: DocsParser = () => ['docs'];
private scriptCodeFactory: ScriptCodeFactory = createScriptCodeFactoryStub({
defaultCodePrefix: TestContext.name,
});
public withCodeValidator(codeValidator: ICodeValidator): this {
this.codeValidator = codeValidator; this.codeValidator = codeValidator;
return this; return this;
} }
public withData(data: ScriptData): this { public withData(data: ScriptData) {
this.data = data; this.data = data;
return this; return this;
} }
public withContext(context: ICategoryCollectionParseContext): this { public withContext(context: ICategoryCollectionParseContext) {
this.context = context; this.context = context;
return this; return this;
} }
public withParser(parser: IEnumParser<RecommendationLevel>): this { public withParser(parser: IEnumParser<RecommendationLevel>) {
this.levelParser = parser; this.parser = parser;
return this; return this;
} }
public withScriptFactory(scriptFactory: ScriptFactory): this { public withFactory(factory: ScriptFactoryType) {
this.scriptFactory = scriptFactory; this.factory = factory;
return this;
}
public withValidatorFactory(validatorFactory: NodeDataValidatorFactory): this {
this.validatorFactory = validatorFactory;
return this;
}
public withErrorWrapper(errorWrapper: ErrorWithContextWrapper): this {
this.errorWrapper = errorWrapper;
return this;
}
public withScriptCodeFactory(scriptCodeFactory: ScriptCodeFactory): this {
this.scriptCodeFactory = scriptCodeFactory;
return this;
}
public withDocsParser(docsParser: DocsParser): this {
this.docsParser = docsParser;
return this; return this;
} }
public parseScript(): Script { public parseScript(): Script {
return parseScript( return parseScript(this.data, this.context, this.parser, this.factory, this.codeValidator);
this.data,
this.context,
{
levelParser: this.levelParser,
createScript: this.scriptFactory,
codeValidator: this.codeValidator,
wrapError: this.errorWrapper,
createValidator: this.validatorFactory,
createCode: this.scriptCodeFactory,
parseDocs: this.docsParser,
},
);
} }
} }

View File

@@ -2,13 +2,13 @@ import { describe, it, expect } from 'vitest';
import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator'; import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator';
import { CodeValidationRuleStub } from '@tests/unit/shared/Stubs/CodeValidationRuleStub'; import { CodeValidationRuleStub } from '@tests/unit/shared/Stubs/CodeValidationRuleStub';
import { itEachAbsentCollectionValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { itEachAbsentCollectionValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
import type { ICodeLine } from '@/application/Parser/Script/Validation/ICodeLine'; import type { ICodeLine } from '@/application/Parser/Script/Validation/ICodeLine';
import type { ICodeValidationRule, IInvalidCodeLine } from '@/application/Parser/Script/Validation/ICodeValidationRule'; import type { ICodeValidationRule, IInvalidCodeLine } from '@/application/Parser/Script/Validation/ICodeValidationRule';
describe('CodeValidator', () => { describe('CodeValidator', () => {
describe('instance', () => { describe('instance', () => {
itIsSingletonFactory({ itIsSingleton({
getter: () => CodeValidator.instance, getter: () => CodeValidator.instance,
expectedType: CodeValidator, expectedType: CodeValidator,
}); });

View File

@@ -3,68 +3,50 @@ import { Category } from '@/domain/Category';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import type { ICategory, IScript } from '@/domain/ICategory';
describe('Category', () => { describe('Category', () => {
describe('ctor', () => { describe('ctor', () => {
describe('throws error if name is absent', () => { describe('throws when name is absent', () => {
itEachAbsentStringValue((absentValue) => { itEachAbsentStringValue((absentValue) => {
// arrange // arrange
const expectedError = 'missing name'; const expectedError = 'missing name';
const name = absentValue; const name = absentValue;
// act // act
const construct = () => new CategoryBuilder() const construct = () => new Category(5, name, [], [new CategoryStub(5)], []);
.withName(name)
.build();
// assert // assert
expect(construct).to.throw(expectedError); expect(construct).to.throw(expectedError);
}, { excludeNull: true, excludeUndefined: true }); }, { excludeNull: true, excludeUndefined: true });
}); });
it('throws error if no children are present', () => { it('throws when has no children', () => {
// arrange
const expectedError = 'A category must have at least one sub-category or script'; const expectedError = 'A category must have at least one sub-category or script';
const scriptChildren: readonly IScript[] = []; const construct = () => new Category(5, 'category', [], [], []);
const categoryChildren: readonly ICategory[] = [];
// act
const construct = () => new CategoryBuilder()
.withSubcategories(categoryChildren)
.withScripts(scriptChildren)
.build();
// assert
expect(construct).to.throw(expectedError); expect(construct).to.throw(expectedError);
}); });
}); });
describe('getAllScriptsRecursively', () => { describe('getAllScriptsRecursively', () => {
it('retrieves direct child scripts', () => { it('gets child scripts', () => {
// arrange // arrange
const expectedScripts = [new ScriptStub('1'), new ScriptStub('2')]; const expected = [new ScriptStub('1'), new ScriptStub('2')];
const sut = new CategoryBuilder() const sut = new Category(0, 'category', [], [], expected);
.withScripts(expectedScripts)
.build();
// act // act
const actual = sut.getAllScriptsRecursively(); const actual = sut.getAllScriptsRecursively();
// assert // assert
expect(actual).to.have.deep.members(expectedScripts); expect(actual).to.have.deep.members(expected);
}); });
it('retrieves scripts from direct child categories', () => { it('gets child categories', () => {
// arrange // arrange
const expectedScriptIds = ['1', '2', '3', '4']; const expectedScriptIds = ['1', '2', '3', '4'];
const categories = [ const categories = [
new CategoryStub(31).withScriptIds('1', '2'), new CategoryStub(31).withScriptIds('1', '2'),
new CategoryStub(32).withScriptIds('3', '4'), new CategoryStub(32).withScriptIds('3', '4'),
]; ];
const sut = new CategoryBuilder() const sut = new Category(0, 'category', [], categories, []);
.withScripts([])
.withSubcategories(categories)
.build();
// act // act
const actualIds = sut const actualIds = sut.getAllScriptsRecursively().map((s) => s.id);
.getAllScriptsRecursively()
.map((s) => s.id);
// assert // assert
expect(actualIds).to.have.deep.members(expectedScriptIds); expect(actualIds).to.have.deep.members(expectedScriptIds);
}); });
it('retrieves scripts from both direct children and child categories', () => { it('gets child scripts and categories', () => {
// arrange // arrange
const expectedScriptIds = ['1', '2', '3', '4', '5', '6']; const expectedScriptIds = ['1', '2', '3', '4', '5', '6'];
const categories = [ const categories = [
@@ -72,18 +54,13 @@ describe('Category', () => {
new CategoryStub(32).withScriptIds('3', '4'), new CategoryStub(32).withScriptIds('3', '4'),
]; ];
const scripts = [new ScriptStub('5'), new ScriptStub('6')]; const scripts = [new ScriptStub('5'), new ScriptStub('6')];
const sut = new CategoryBuilder() const sut = new Category(0, 'category', [], categories, scripts);
.withSubcategories(categories)
.withScripts(scripts)
.build();
// act // act
const actualIds = sut const actualIds = sut.getAllScriptsRecursively().map((s) => s.id);
.getAllScriptsRecursively()
.map((s) => s.id);
// assert // assert
expect(actualIds).to.have.deep.members(expectedScriptIds); expect(actualIds).to.have.deep.members(expectedScriptIds);
}); });
it('retrieves scripts from nested categories recursively', () => { it('gets child categories recursively', () => {
// arrange // arrange
const expectedScriptIds = ['1', '2', '3', '4', '5', '6']; const expectedScriptIds = ['1', '2', '3', '4', '5', '6'];
const categories = [ const categories = [
@@ -106,111 +83,45 @@ describe('Category', () => {
), ),
]; ];
// assert // assert
const sut = new CategoryBuilder() const sut = new Category(0, 'category', [], categories, []);
.withScripts([])
.withSubcategories(categories)
.build();
// act // act
const actualIds = sut const actualIds = sut.getAllScriptsRecursively().map((s) => s.id);
.getAllScriptsRecursively()
.map((s) => s.id);
// assert // assert
expect(actualIds).to.have.deep.members(expectedScriptIds); expect(actualIds).to.have.deep.members(expectedScriptIds);
}); });
}); });
describe('includes', () => { describe('includes', () => {
it('returns false for scripts not included', () => { it('return false when does not include', () => {
// assert // assert
const expectedResult = false;
const script = new ScriptStub('3'); const script = new ScriptStub('3');
const childCategory = new CategoryStub(33) const sut = new Category(0, 'category', [], [new CategoryStub(33).withScriptIds('1', '2')], []);
.withScriptIds('1', '2');
const sut = new CategoryBuilder()
.withSubcategories([childCategory])
.build();
// act // act
const actual = sut.includes(script); const actual = sut.includes(script);
// assert // assert
expect(actual).to.equal(expectedResult); expect(actual).to.equal(false);
}); });
it('returns true for scripts directly included', () => { it('return true when includes as subscript', () => {
// assert // assert
const expectedResult = true;
const script = new ScriptStub('3'); const script = new ScriptStub('3');
const childCategory = new CategoryStub(33) const sut = new Category(0, 'category', [], [
.withScript(script) new CategoryStub(33).withScript(script).withScriptIds('non-related'),
.withScriptIds('non-related'); ], []);
const sut = new CategoryBuilder()
.withSubcategories([childCategory])
.build();
// act // act
const actual = sut.includes(script); const actual = sut.includes(script);
// assert // assert
expect(actual).to.equal(expectedResult); expect(actual).to.equal(true);
}); });
it('returns true for scripts included in nested categories', () => { it('return true when includes as nested category script', () => {
// assert // assert
const expectedResult = true;
const script = new ScriptStub('3'); const script = new ScriptStub('3');
const childCategory = new CategoryStub(22) const innerCategory = new CategoryStub(22)
.withScriptIds('non-related') .withScriptIds('non-related')
.withCategory(new CategoryStub(33).withScript(script)); .withCategory(new CategoryStub(33).withScript(script));
const sut = new CategoryBuilder() const sut = new Category(11, 'category', [], [innerCategory], []);
.withSubcategories([childCategory])
.build();
// act // act
const actual = sut.includes(script); const actual = sut.includes(script);
// assert // assert
expect(actual).to.equal(expectedResult); expect(actual).to.equal(true);
}); });
}); });
}); });
class CategoryBuilder {
private id = 3264;
private name = 'test-script';
private docs: ReadonlyArray<string> = [];
private subcategories: ReadonlyArray<ICategory> = [];
private scripts: ReadonlyArray<IScript> = [
new ScriptStub(`[${CategoryBuilder.name}] script`),
];
public withId(id: number): this {
this.id = id;
return this;
}
public withName(name: string): this {
this.name = name;
return this;
}
public withDocs(docs: ReadonlyArray<string>): this {
this.docs = docs;
return this;
}
public withScripts(scripts: ReadonlyArray<IScript>): this {
this.scripts = scripts;
return this;
}
public withSubcategories(subcategories: ReadonlyArray<ICategory>): this {
this.subcategories = subcategories;
return this;
}
public build(): Category {
return new Category({
id: this.id,
name: this.name,
docs: this.docs,
subcategories: this.subcategories,
scripts: this.scripts,
});
}
}

View File

@@ -8,7 +8,7 @@ import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
describe('Script', () => { describe('Script', () => {
describe('ctor', () => { describe('ctor', () => {
describe('scriptCode', () => { describe('scriptCode', () => {
it('assigns code correctly', () => { it('sets as expected', () => {
// arrange // arrange
const expected = new ScriptCodeStub(); const expected = new ScriptCodeStub();
const sut = new ScriptBuilder() const sut = new ScriptBuilder()
@@ -43,7 +43,7 @@ describe('Script', () => {
}); });
}); });
describe('level', () => { describe('level', () => {
it('throws when constructed with invalid level', () => { it('cannot construct with invalid wrong value', () => {
// arrange // arrange
const invalidValue: RecommendationLevel = 55 as never; const invalidValue: RecommendationLevel = 55 as never;
const expectedError = 'invalid level'; const expectedError = 'invalid level';
@@ -54,7 +54,7 @@ describe('Script', () => {
// assert // assert
expect(construct).to.throw(expectedError); expect(construct).to.throw(expectedError);
}); });
it('handles undefined level correctly', () => { it('sets undefined as expected', () => {
// arrange // arrange
const expected = undefined; const expected = undefined;
// act // act
@@ -64,7 +64,7 @@ describe('Script', () => {
// assert // assert
expect(sut.level).to.equal(expected); expect(sut.level).to.equal(expected);
}); });
it('correctly assigns valid recommendation levels', () => { it('sets as expected', () => {
// arrange // arrange
for (const expected of getEnumValues(RecommendationLevel)) { for (const expected of getEnumValues(RecommendationLevel)) {
// act // act
@@ -78,7 +78,7 @@ describe('Script', () => {
}); });
}); });
describe('docs', () => { describe('docs', () => {
it('correctly assigns docs', () => { it('sets as expected', () => {
// arrange // arrange
const expected = ['doc1', 'doc2']; const expected = ['doc1', 'doc2'];
// act // act
@@ -130,11 +130,11 @@ class ScriptBuilder {
} }
public build(): Script { public build(): Script {
return new Script({ return new Script(
name: this.name, this.name,
code: this.code, this.code,
docs: this.docs, this.docs,
level: this.level, this.level,
}); );
} }
} }

View File

@@ -1,52 +0,0 @@
import { createScriptCode } from '@/domain/ScriptCodeFactory';
describe('ScriptCodeFactory', () => {
describe('createScriptCode', () => {
it('generates script code with given `code`', () => {
// arrange
const expectedCode = 'expected code';
const context = new TestContext()
.withCode(expectedCode);
// act
const code = context.createScriptCode();
// assert
const actualCode = code.execute;
expect(actualCode).to.equal(expectedCode);
});
it('generates script code with given `revertCode`', () => {
// arrange
const expectedRevertCode = 'expected revert code';
const context = new TestContext()
.withRevertCode(expectedRevertCode);
// act
const code = context.createScriptCode();
// assert
const actualRevertCode = code.revert;
expect(actualRevertCode).to.equal(expectedRevertCode);
});
});
});
class TestContext {
private code = `[${TestContext}] code`;
private revertCode = `[${TestContext}] revertCode`;
public withCode(code: string): this {
this.code = code;
return this;
}
public withRevertCode(revertCode: string): this {
this.revertCode = revertCode;
return this;
}
public createScriptCode(): ReturnType<typeof createScriptCode> {
return createScriptCode(
this.code,
this.revertCode,
);
}
}

View File

@@ -1,7 +1,7 @@
import { import {
describe, describe,
} from 'vitest'; } from 'vitest';
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
import { EnvironmentVariablesFactory, type EnvironmentVariablesValidator } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory'; import { EnvironmentVariablesFactory, type EnvironmentVariablesValidator } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
import { ViteEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/Vite/ViteEnvironmentVariables'; import { ViteEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/Vite/ViteEnvironmentVariables';
import type { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables'; import type { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
@@ -9,7 +9,7 @@ import { expectExists } from '@tests/shared/Assertions/ExpectExists';
describe('EnvironmentVariablesFactory', () => { describe('EnvironmentVariablesFactory', () => {
describe('instance', () => { describe('instance', () => {
itIsSingletonFactory({ itIsSingleton({
getter: () => EnvironmentVariablesFactory.Current.instance, getter: () => EnvironmentVariablesFactory.Current.instance,
expectedType: ViteEnvironmentVariables, expectedType: ViteEnvironmentVariables,
}); });

View File

@@ -3,9 +3,8 @@ import { VueDependencyInjectionApiStub } from '@tests/unit/shared/Stubs/VueDepen
import { InjectionKeys } from '@/presentation/injectionSymbols'; import { InjectionKeys } from '@/presentation/injectionSymbols';
import { provideDependencies, type VueDependencyInjectionApi } from '@/presentation/bootstrapping/DependencyProvider'; import { provideDependencies, type VueDependencyInjectionApi } from '@/presentation/bootstrapping/DependencyProvider';
import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationContextStub'; import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationContextStub';
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
import type { IApplicationContext } from '@/application/Context/IApplicationContext'; import type { IApplicationContext } from '@/application/Context/IApplicationContext';
import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests';
describe('DependencyProvider', () => { describe('DependencyProvider', () => {
describe('provideDependencies', () => { describe('provideDependencies', () => {
@@ -47,22 +46,19 @@ function createTransientTests() {
const registeredObject = api.inject(injectionKey); const registeredObject = api.inject(injectionKey);
expect(registeredObject).to.be.instanceOf(Function); expect(registeredObject).to.be.instanceOf(Function);
}); });
describe('should return different instances for transient dependency', () => { it('should return different instances for transient dependency', () => {
// arrange // arrange
const api = new VueDependencyInjectionApiStub(); const api = new VueDependencyInjectionApiStub();
// act
new ProvideDependenciesBuilder() new ProvideDependenciesBuilder()
.withApi(api) .withApi(api)
.provideDependencies(); .provideDependencies();
// act // expect
const getFactoryResult = () => {
const registeredObject = api.inject(injectionKey); const registeredObject = api.inject(injectionKey);
const factory = registeredObject as () => unknown; const factory = registeredObject as () => unknown;
return factory(); const firstResult = factory();
}; const secondResult = factory();
// assert expect(firstResult).to.not.equal(secondResult);
itIsTransientFactory({
getter: getFactoryResult,
});
}); });
}; };
} }
@@ -91,7 +87,7 @@ function createSingletonTests() {
// act // act
const getRegisteredInstance = () => api.inject(injectionKey); const getRegisteredInstance = () => api.inject(injectionKey);
// assert // assert
itIsSingletonFactory({ itIsSingleton({
getter: getRegisteredInstance, getter: getRegisteredInstance,
}); });
}); });

View File

@@ -6,7 +6,8 @@ import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollect
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { getCategoryNodeId, getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter'; import { getCategoryNodeId, getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
import { type NodeMetadata, NodeType } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import type { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
describe('ReverterFactory', () => { describe('ReverterFactory', () => {
describe('getReverter', () => { describe('getReverter', () => {

View File

@@ -8,7 +8,7 @@ import {
getCategoryId, getCategoryNodeId, getScriptId, getCategoryId, getCategoryNodeId, getScriptId,
getScriptNodeId, parseAllCategories, parseSingleCategory, getScriptNodeId, parseAllCategories, parseSingleCategory,
} from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter'; } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
import { NodeDataType } from '@/application/Parser/NodeValidation/NodeDataType'; import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import type { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; import type { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
@@ -109,7 +109,7 @@ function isReversible(category: ICategory): boolean {
} }
function expectSameCategory(node: NodeMetadata, category: ICategory): void { function expectSameCategory(node: NodeMetadata, category: ICategory): void {
expect(node.type).to.equal(NodeDataType.Category, getErrorMessage('type')); expect(node.type).to.equal(NodeType.Category, getErrorMessage('type'));
expect(node.id).to.equal(getCategoryNodeId(category), getErrorMessage('id')); expect(node.id).to.equal(getCategoryNodeId(category), getErrorMessage('id'));
expect(node.docs).to.equal(category.docs, getErrorMessage('docs')); expect(node.docs).to.equal(category.docs, getErrorMessage('docs'));
expect(node.text).to.equal(category.name, getErrorMessage('name')); expect(node.text).to.equal(category.name, getErrorMessage('name'));
@@ -136,7 +136,7 @@ function expectSameCategory(node: NodeMetadata, category: ICategory): void {
} }
function expectSameScript(node: NodeMetadata, script: IScript): void { function expectSameScript(node: NodeMetadata, script: IScript): void {
expect(node.type).to.equal(NodeDataType.Script, getErrorMessage('type')); expect(node.type).to.equal(NodeType.Script, getErrorMessage('type'));
expect(node.id).to.equal(getScriptNodeId(script), getErrorMessage('id')); expect(node.id).to.equal(getScriptNodeId(script), getErrorMessage('id'));
expect(node.docs).to.equal(script.docs, getErrorMessage('docs')); expect(node.docs).to.equal(script.docs, getErrorMessage('docs'));
expect(node.text).to.equal(script.name, getErrorMessage('name')); expect(node.text).to.equal(script.name, getErrorMessage('name'));

View File

@@ -3,14 +3,14 @@ import { describe, it } from 'vitest';
import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import type { Logger } from '@/application/Common/Log/Logger'; import type { Logger } from '@/application/Common/Log/Logger';
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub'; import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub'; import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { ClientLoggerFactory, type LoggerCreationFunction, type WindowAccessor } from '@/presentation/components/Shared/Hooks/Log/ClientLoggerFactory'; import { ClientLoggerFactory, type LoggerCreationFunction, type WindowAccessor } from '@/presentation/components/Shared/Hooks/Log/ClientLoggerFactory';
describe('ClientLoggerFactory', () => { describe('ClientLoggerFactory', () => {
describe('Current', () => { describe('Current', () => {
describe('singleton behavior', () => { describe('singleton behavior', () => {
itIsSingletonFactory({ itIsSingleton({
getter: () => ClientLoggerFactory.Current, getter: () => ClientLoggerFactory.Current,
expectedType: ClientLoggerFactory, expectedType: ClientLoggerFactory,
}); });

View File

@@ -1,18 +1,12 @@
export function collectExceptionMessage(action: () => unknown): string { export function collectExceptionMessage(action: () => unknown): string {
return collectException(action).message; let message: string | undefined;
}
function collectException(
action: () => unknown,
): Error {
let error: Error | undefined;
try { try {
action(); action();
} catch (err) { } catch (e) {
error = err; message = e.message;
} }
if (!error) { if (!message) {
throw new Error('action did not throw'); throw new Error('action did not throw');
} }
return error; return message;
} }

View File

@@ -1,19 +0,0 @@
import type { CategoryFactory } from '@/application/Parser/CategoryParser';
import type { CategoryInitParameters } from '@/domain/Category';
import type { ICategory } from '@/domain/ICategory';
import { CategoryStub } from './CategoryStub';
export function createCategoryFactorySpy(): {
readonly categoryFactorySpy: CategoryFactory;
getInitParameters: (category: ICategory) => CategoryInitParameters | undefined;
} {
const createdCategories = new Map<ICategory, CategoryInitParameters>();
return {
categoryFactorySpy: (parameters) => {
const category = new CategoryStub(55);
createdCategories.set(category, parameters);
return category;
},
getInitParameters: (category) => createdCategories.get(category),
};
}

View File

@@ -19,16 +19,16 @@ export class CodeValidatorStub implements ICodeValidator {
}); });
} }
public assertHistory(expectation: { public assertHistory(expected: {
validatedCodes: readonly (string | undefined)[], validatedCodes: readonly (string | undefined)[],
rules: readonly Constructible<ICodeValidationRule>[], rules: readonly Constructible<ICodeValidationRule>[],
}) { }) {
expect(this.callHistory).to.have.lengthOf(expectation.validatedCodes.length); expect(this.callHistory).to.have.lengthOf(expected.validatedCodes.length);
const actualValidatedCodes = this.callHistory.map((args) => args.code); const actualValidatedCodes = this.callHistory.map((args) => args.code);
expect(actualValidatedCodes.sort()).deep.equal([...expectation.validatedCodes].sort()); expect(actualValidatedCodes.sort()).deep.equal([...expected.validatedCodes].sort());
for (const call of this.callHistory) { for (const call of this.callHistory) {
const actualRules = call.rules.map((rule) => rule.constructor); const actualRules = call.rules.map((rule) => rule.constructor);
expect(actualRules.sort()).to.deep.equal([...expectation.rules].sort()); expect(actualRules.sort()).to.deep.equal([...expected.rules].sort());
} }
} }
} }

View File

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

View File

@@ -1,67 +0,0 @@
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
export class ErrorWrapperStub {
private errorToReturn: Error | undefined;
private parameters?: Parameters<ErrorWithContextWrapper>;
public get lastError(): Error | undefined {
if (!this.parameters) {
return undefined;
}
return getError(this.parameters);
}
public get lastContext(): string | undefined {
if (!this.parameters) {
return undefined;
}
return getAdditionalContext(this.parameters);
}
public withError(error: Error): this {
this.errorToReturn = error;
return this;
}
public get(): ErrorWithContextWrapper {
return (...args) => {
this.parameters = args;
if (this.errorToReturn) {
return this.errorToReturn;
}
return new Error(
`[${ErrorWrapperStub.name}] Error wrapped with additional context.`
+ `\nAdditional context: ${getAdditionalContext(args)}`
+ `\nWrapped error message: ${getError(args).message}`
+ `\nWrapped error stack trace:\n${getLimitedStackTrace(getError(args), 5)}`,
);
};
}
}
function getAdditionalContext(
parameters: Parameters<ErrorWithContextWrapper>,
): string {
return parameters[1];
}
function getError(
parameters: Parameters<ErrorWithContextWrapper>,
): Error {
return parameters[0];
}
function getLimitedStackTrace(
error: Error,
limit: number,
): string {
const { stack } = error;
if (!stack) {
return 'No stack trace available';
}
return stack
.split('\n')
.slice(0, limit + 1)
.join('\n');
}

View File

@@ -2,33 +2,22 @@ import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function
import type { FunctionCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler'; import type { FunctionCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler';
import type { ISharedFunctionCollection } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionCollection'; import type { ISharedFunctionCollection } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionCollection';
import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall'; import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
import { CompiledCodeStub } from './CompiledCodeStub';
interface FunctionCallCompilationTestScenario { interface IScenario {
readonly calls: FunctionCall[]; calls: FunctionCall[];
readonly functions: ISharedFunctionCollection; functions: ISharedFunctionCollection;
readonly result: CompiledCode; result: CompiledCode;
} }
export class FunctionCallCompilerStub implements FunctionCallCompiler { export class FunctionCallCompilerStub implements FunctionCallCompiler {
public scenarios = new Array<FunctionCallCompilationTestScenario>(); public scenarios = new Array<IScenario>();
private defaultCompiledCode: CompiledCode = new CompiledCodeStub()
.withCode(`[${FunctionCallCompilerStub.name}] function code`)
.withRevertCode(`[${FunctionCallCompilerStub.name}] function revert code`);
public setup( public setup(
calls: FunctionCall[], calls: FunctionCall[],
functions: ISharedFunctionCollection, functions: ISharedFunctionCollection,
result: CompiledCode, result: CompiledCode,
): this { ) {
this.scenarios.push({ calls, functions, result }); this.scenarios.push({ calls, functions, result });
return this;
}
public withDefaultCompiledCode(defaultCompiledCode: CompiledCode): this {
this.defaultCompiledCode = defaultCompiledCode;
return this;
} }
public compileFunctionCalls( public compileFunctionCalls(
@@ -40,7 +29,10 @@ export class FunctionCallCompilerStub implements FunctionCallCompiler {
if (predefined) { if (predefined) {
return predefined.result; return predefined.result;
} }
return this.defaultCompiledCode; return {
code: 'function code [FunctionCallCompilerStub]',
revertCode: 'function revert code [FunctionCallCompilerStub]',
};
} }
} }

View File

@@ -16,7 +16,7 @@ export class FunctionParameterCollectionStub implements IFunctionParameterCollec
public withParameterName(parameterName: string, isOptional = true) { public withParameterName(parameterName: string, isOptional = true) {
const parameter = new FunctionParameterStub() const parameter = new FunctionParameterStub()
.withName(parameterName) .withName(parameterName)
.withOptional(isOptional); .withOptionality(isOptional);
this.addParameter(parameter); this.addParameter(parameter);
return this; return this;
} }

View File

@@ -10,7 +10,7 @@ export class FunctionParameterStub implements IFunctionParameter {
return this; return this;
} }
public withOptional(isOptional: boolean) { public withOptionality(isOptional: boolean) {
this.isOptional = isOptional; this.isOptional = isOptional;
return this; return this;
} }

View File

@@ -1,11 +1,12 @@
import type { NodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext'; import type { NodeData } from '@/application/Parser/NodeValidation/NodeData';
import { NodeDataType } from '@/application/Parser/NodeValidation/NodeDataType'; import type { INodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataError';
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import { CategoryDataStub } from './CategoryDataStub'; import { CategoryDataStub } from './CategoryDataStub';
export function createNodeDataErrorContextStub(): NodeDataErrorContext { export class NodeDataErrorContextStub implements INodeDataErrorContext {
return { public readonly type: NodeType = NodeType.Script;
type: NodeDataType.Category,
selfNode: new CategoryDataStub(), public readonly selfNode: NodeData = new CategoryDataStub();
parentNode: new CategoryDataStub(),
}; public readonly parentNode?: NodeData;
} }

View File

@@ -1,57 +0,0 @@
import type { NodeData } from '@/application/Parser/NodeValidation/NodeData';
import type { NodeDataValidator, NodeDataValidatorFactory } from '@/application/Parser/NodeValidation/NodeDataValidator';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export const createNodeDataValidatorFactoryStub
: NodeDataValidatorFactory = () => new NodeDataValidatorStub();
export class NodeDataValidatorStub
extends StubWithObservableMethodCalls<NodeDataValidator>
implements NodeDataValidator {
private assertThrowsOnFalseCondition = true;
public withAssertThrowsOnFalseCondition(enableAssertThrows: boolean): this {
this.assertThrowsOnFalseCondition = enableAssertThrows;
return this;
}
public assertValidName(nameValue: string): this {
this.registerMethodCall({
methodName: 'assertValidName',
args: [nameValue],
});
return this;
}
public assertDefined(node: NodeData): this {
this.registerMethodCall({
methodName: 'assertDefined',
args: [node],
});
return this;
}
public assert(
validationPredicate: () => boolean,
errorMessage: string,
): this {
this.registerMethodCall({
methodName: 'assert',
args: [validationPredicate, errorMessage],
});
if (this.assertThrowsOnFalseCondition) {
if (!validationPredicate()) {
throw new Error(`[${NodeDataValidatorStub.name}] Assert validation failed: ${errorMessage}`);
}
}
return this;
}
public createContextualErrorMessage(errorMessage: string): string {
this.registerMethodCall({
methodName: 'createContextualErrorMessage',
args: [errorMessage],
});
return `${NodeDataValidatorStub.name}: ${errorMessage}`;
}
}

View File

@@ -1,22 +0,0 @@
import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
import type { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCodeStub } from './ScriptCodeStub';
export function createScriptCodeFactoryStub(
options?: Partial<StubOptions>,
): ScriptCodeFactory {
let defaultCodePrefix = 'createScriptCodeFactoryStub';
if (options?.defaultCodePrefix) {
defaultCodePrefix += ` > ${options?.defaultCodePrefix}`;
}
return (
() => options?.scriptCode ?? new ScriptCodeStub()
.withExecute(`[${defaultCodePrefix}] default code`)
.withRevert(`[${defaultCodePrefix}] revert code`)
);
}
interface StubOptions {
readonly scriptCode?: IScriptCode;
readonly defaultCodePrefix?: string;
}

View File

@@ -1,9 +1,9 @@
import type { IScriptCode } from '@/domain/IScriptCode'; import type { IScriptCode } from '@/domain/IScriptCode';
export class ScriptCodeStub implements IScriptCode { export class ScriptCodeStub implements IScriptCode {
public execute = `[${ScriptCodeStub.name}] default execute code`; public execute = 'default execute code';
public revert = `[${ScriptCodeStub.name}] default revert code`; public revert = 'default revert code';
public withExecute(code: string) { public withExecute(code: string) {
this.execute = code; this.execute = code;

View File

@@ -1,19 +0,0 @@
import type { ScriptFactory } from '@/application/Parser/Script/ScriptParser';
import type { IScript } from '@/domain/IScript';
import type { ScriptInitParameters } from '@/domain/Script';
import { ScriptStub } from './ScriptStub';
export function createScriptFactorySpy(): {
readonly scriptFactorySpy: ScriptFactory;
getInitParameters: (category: IScript) => ScriptInitParameters | undefined;
} {
const createdScripts = new Map<IScript, ScriptInitParameters>();
return {
scriptFactorySpy: (parameters) => {
const script = new ScriptStub('script from factory stub');
createdScripts.set(script, parameters);
return script;
},
getInitParameters: (script) => createdScripts.get(script),
};
}

View File

@@ -1,37 +0,0 @@
import type { ScriptParser } from '@/application/Parser/Script/ScriptParser';
import type { IScript } from '@/domain/IScript';
import type { ScriptData } from '@/application/collections/';
import { ScriptStub } from './ScriptStub';
export class ScriptParserStub {
private readonly parsedScripts = new Map<IScript, Parameters<ScriptParser>>();
private readonly setupScripts = new Map<ScriptData, IScript>();
public get(): ScriptParser {
return (...parameters) => {
const [scriptData] = parameters;
const script = this.setupScripts.get(scriptData)
?? new ScriptStub(
`[${ScriptParserStub.name}] parsed script stub number ${this.parsedScripts.size + 1}`,
);
this.parsedScripts.set(script, parameters);
return script;
};
}
public getParseParameters(
script: IScript,
): Parameters<ScriptParser> {
const parameters = this.parsedScripts.get(script);
if (!parameters) {
throw new Error('Script has never been parsed.');
}
return parameters;
}
public setupParsedResultForData(scriptData: ScriptData, parsedResult: IScript): this {
this.setupScripts.set(scriptData, parsedResult);
return this;
}
}

View File

@@ -5,12 +5,12 @@ import type { FunctionKeys } from '@/TypeHelpers';
export abstract class StubWithObservableMethodCalls<T> { export abstract class StubWithObservableMethodCalls<T> {
public readonly callHistory = new Array<MethodCall<T>>(); public readonly callHistory = new Array<MethodCall<T>>();
private readonly notifiableMethodCalls = new EventSource<MethodCall<T>>();
public get methodCalls(): IEventSource<MethodCall<T>> { public get methodCalls(): IEventSource<MethodCall<T>> {
return this.notifiableMethodCalls; return this.notifiableMethodCalls;
} }
private readonly notifiableMethodCalls = new EventSource<MethodCall<T>>();
protected registerMethodCall(name: MethodCall<T>) { protected registerMethodCall(name: MethodCall<T>) {
this.callHistory.push(name); this.callHistory.push(name);
this.notifiableMethodCalls.notify(name); this.notifiableMethodCalls.notify(name);

View File

@@ -1,12 +1,12 @@
import { it, expect } from 'vitest'; import { it, expect } from 'vitest';
import type { Constructible } from '@/TypeHelpers'; import type { Constructible } from '@/TypeHelpers';
interface SingletonTestData<T> { interface ISingletonTestData<T> {
readonly getter: () => T; readonly getter: () => T;
readonly expectedType?: Constructible<T>; readonly expectedType?: Constructible<T>;
} }
export function itIsSingletonFactory<T>(test: SingletonTestData<T>): void { export function itIsSingleton<T>(test: ISingletonTestData<T>): void {
if (test.expectedType !== undefined) { if (test.expectedType !== undefined) {
it('gets the expected type', () => { it('gets the expected type', () => {
// act // act

View File

@@ -1,24 +0,0 @@
import type { Constructible } from '@/TypeHelpers';
interface TransientFactoryTestData<T> {
readonly getter: () => T;
readonly expectedType?: Constructible<T>;
}
export function itIsTransientFactory<T>(test: TransientFactoryTestData<T>): void {
if (test.expectedType !== undefined) {
it('gets the expected type', () => {
// act
const instance = test.getter();
// assert
expect(instance).to.be.instanceOf(test.expectedType);
});
}
it('multiple calls get different instances', () => {
// act
const instance1 = test.getter();
const instance2 = test.getter();
// assert
expect(instance1).to.not.equal(instance2);
});
}