Compare commits

...

11 Commits

Author SHA1 Message Date
undergroundwires
704a3d0417 Fix card height inconsistency
TODO: Is CardLayout, card-layout, cardLayout etc., best names possiuble?

Fix card expansion panel heights not being equal. This is due to
limitations in CSS flex view.

Heights of all cards are `100%` which gives them uniform loook on same
row with all equal heights. However, their heights are set to `auto`
when of the cards are open. In that case, their sizes are no longer
equal in same row, cards with longer text/titles keeping more space.
This is because when a card is open, its expansion panel grows in its
own DOM element, increasing the height of the card.
at same height on each line, their heights change completely when one of

Heights gets fucked up when card is collapsing, this is big fix, fix this.

Supporting changes:

- Move card expander to its own component `CardExpansionPanel`.
- Introduce `UseCardLayout` hook to do calculations instead of CSS to
  circumvent limitations in CSS flex view.
2024-05-28 16:20:20 +02:00
undergroundwires
22d6c7991e ci/cd: centralize and bump artifact uploads
- Upgrade `actions/upload-artifact` to `v4` to address deprecation
  warnings related to Node.js 16, improving compatibility with GitHub
  runners. This resolves the following warning from the runners:
  > Node.js 16 actions are deprecated. Please update the following actions
  > to use Node.js 20: actions/upload-artifact@v3.
- Centralize the use of the `upload-artifact` action through a new
  custom action, improving maintainability and consistency across
  workflows.
2024-05-28 12:53:45 +02:00
undergroundwires-bot
795b7f0321 ⬆️ bump everywhere to 0.13.4 2024-05-27 13:45:04 +00:00
undergroundwires
9e34e64449 win, mac, linux: fix typos and dead URLs #367
- Fix multiple spelling errors in various scripts.
- Fix dead URLs with archived versions.
- Fix incorrect registry keys previously introduced in commit
  cec0b4b4f6.

Co-authored-by: RainRat <rainrat78@yahoo.ca>
2024-05-27 14:54:17 +02:00
undergroundwires
ce4cfdd169 win: add script to disable Recall feature 2024-05-27 10:44:11 +02:00
undergroundwires
12b1f183f7 win: document disabling firewall #115 #152 #364
This commit updates documentation to clarify the impacts of disabling
firewall services, specifically how they affect Windows Sandbox, Docker
and WSL.

This update responds to user feedback from issues #115, #152, #364. The
documentation now guides users more clearly on the consequences of their
actions, potentially preventing unintended service disruptions.

Changes include:

- Expand the caution notes to explicitly mention the impact on
  virtualization and isolation features like Windows Sandbox, Docker and
  WSL.
- Expand script titles to briefly mention affects on these features.
- Expand documentation to suggest system restart.
- Add an informative message to restart the computer in terminal outputs
  after service changes to ensure the settings are applied.
2024-05-26 13:42:25 +02:00
undergroundwires
4212c7b9e0 Improve context for errors thrown by compiler
This commit introduces a custom error object to provide additional
context for errors throwing during parsing and compiling operations,
improving troubleshooting.

By integrating error context handling, the error messages become more
informative and user-friendly, providing sequence of trace with context
to aid in troubleshooting.

Changes include:

- Introduce custom error object that extends errors with contextual
  information. This replaces previous usages of `AggregateError` which
  is not displayed well by browsers when logged.
- Improve parsing functions to encapsulate error context with more
  details.
- Increase unit test coverage and refactor the related code to be more
  testable.
2024-05-25 13:55:30 +02:00
undergroundwires
7794846185 win: discourage blocking app access #121 #339 #350
This commit adjusts the recommendation level for scripts that disable
UWP app access to accommodate user issues #121, #339, #350. It also
extends their documentation to reflect the new changes and with
cautions.

Changes:

- Add caution text for all scripts about potential impacts.
- Move disabling app access to notifications from 'Standard' to
  'Strict'. This addresses #121 and #339, where users report lack of
  notification as unintended side-effects.
- Move disabling app access to phone calls from 'Standard' to 'Strict'.
  This addresses #350 where its effect on the Phone Link app was
  reported as an unintended side-effect.
2024-05-24 10:45:23 +02:00
undergroundwires
150e067039 win: improve printing removal /w Print Queue #279
- Consolidate removal of printing UIs under the same category.
- Improve documentation for printing app removal scripts.
- Add removal of previously unlisted 'Print Queue' app, #279.
- Combine removal of `Microsoft.Print3D` and `Windows.Print3D`
  into a single script.
- Highlight the importance of removing 'Print 3D' app due to
  security risks and recommend it on 'Standard'.
2024-05-23 09:27:14 +02:00
undergroundwires
f347fde0c8 win: document and discourage RSA key script #363
This commit improves the documentation of RSA key handling script and
changes its recommendation level to address potential issues with
Hyper-V (as reported in #363).

Changes:

- Add documentation to describe potential disruptions caused by stronger
  RSA key requirements.
- Move RSA key script from 'Standard' to 'Strict' due to its impact on
  Hyper-V VMs.
- Use bullet points for easier expansion in cautions of secret key
  hardening scripts.
2024-05-22 08:10:37 +02:00
undergroundwires
ff3d5c4841 win: improve app access disabling and docs #138
This commit improves disabling app access by correcting minor issues,
adding missing access control configurations and adding more
documentation to increase maintainability and user understanding of
privacy settings, resolving #138.

- Introduce shared functions to streamline modifications of app access,
  improving maintainability and simplifying the codebase.
- Move disabling app access to first position in the category.
- Improve code comments for better clarity on generated outputs.
- Resolve error display in revert codes due to incorrect use of `reg
  delete` commands.
- Fix disabling app access to trusted devices disables access to account
  information, name and picture on older versions of Windows.
- Add missing privacy settings and configurations.
- Add more documentation to scripts
- Rename script names for consistency and clarity.
- Move disabling access to SMS/MMS to phone access disablement category.
- Set empty `REG_MULTI_SZ` values to null for GPO access settings to
  maintain registry integrity.
2024-05-21 13:02:48 +02:00
92 changed files with 5162 additions and 1914 deletions

View File

@@ -0,0 +1,15 @@
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

@@ -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: actions/upload-artifact@v3 uses: ./.github/actions/upload-artifact
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: actions/upload-artifact@v3 uses: ./.github/actions/upload-artifact
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: actions/upload-artifact@v3 uses: ./.github/actions/upload-artifact
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,5 +1,27 @@
# 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.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). - 🖥️ **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).
See also: See also:

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "privacy.sexy", "name": "privacy.sexy",
"version": "0.13.3", "version": "0.13.4",
"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.3", "version": "0.13.4",
"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 } from '@/TypeHelpers'; import { isFunction, type ConstructorArguments } 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 } 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(message?: string, options?: ErrorOptions) { constructor(...args: ConstructorArguments<typeof Error>) {
super(message, options); super(...args);
fixPrototype(this, new.target.prototype); fixPrototype(this, new.target.prototype);
ensureStackTrace(this); ensureStackTrace(this);

View File

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

@@ -0,0 +1,42 @@
import { CustomError } from '@/application/Common/CustomError';
export interface ErrorWithContextWrapper {
(
error: Error,
additionalContext: string,
): Error;
}
export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = (
error: Error,
additionalContext: string,
) => {
return (error instanceof ContextualError ? error : new ContextualError(error))
.withAdditionalContext(additionalContext);
};
/* AggregateError is similar but isn't well-serialized or displayed 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 function parseDocs(documentable: DocumentableData): readonly string[] { export const parseDocs: DocsParser = (documentable) => {
const { docs } = documentable; const { docs } = documentable;
if (!docs) { if (!docs) {
return []; return [];
@@ -9,6 +9,12 @@ export function parseDocs(documentable: DocumentableData): readonly string[] {
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 IPrimitiveExpression } from '../Parser/Regex/RegexParser'; import { RegexParser, type PrimitiveExpression } 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): IPrimitiveExpression { protected buildExpression(match: RegExpMatchArray): PrimitiveExpression {
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 "${parameterName}"`); throw new Error(`Missing argument value for the parameter "${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('", "')}"`
+ '. Expected parameter(s): ' + '.\nExpected parameter(s): '
+ (expectedParameters.length ? `"${expectedParameters.join('", "')}"` : 'none'), + (expectedParameters.length ? `"${expectedParameters.join('", "')}".` : 'none'),
); );
} }

View File

@@ -6,11 +6,14 @@ import type { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/
import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall'; import type { 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(
@@ -22,18 +25,26 @@ 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,
expressionsCompiler: IExpressionsCompiler, utilities: ArgumentCompilationUtilities,
): IReadOnlyFunctionCallArgumentCollection { ): IReadOnlyFunctionCallArgumentCollection {
const requiredParameterNames = context const requiredParameterNames = context
.allFunctions .allFunctions
@@ -47,7 +58,7 @@ function compileNestedFunctionArguments(
paramName, paramName,
nestedFunction, nestedFunction,
parentFunctionArgs, parentFunctionArgs,
expressionsCompiler, utilities,
), ),
})) }))
// Filter out arguments with absent values // Filter out arguments with absent values
@@ -89,13 +100,13 @@ function compileArgument(
parameterName: string, parameterName: string,
nestedFunction: FunctionCall, nestedFunction: FunctionCall,
parentFunctionArgs: IReadOnlyFunctionCallArgumentCollection, parentFunctionArgs: IReadOnlyFunctionCallArgumentCollection,
expressionsCompiler: IExpressionsCompiler, utilities: ArgumentCompilationUtilities,
): string { ): string {
try { try {
const { argumentValue: codeInArgument } = nestedFunction.args.getArgument(parameterName); const { argumentValue: codeInArgument } = nestedFunction.args.getArgument(parameterName);
return expressionsCompiler.compileExpressions(codeInArgument, parentFunctionArgs); return utilities.expressionsCompiler.compileExpressions(codeInArgument, parentFunctionArgs);
} catch (err) { } catch (error) {
throw new AggregateError([err], `Error when compiling argument for "${parameterName}"`); throw utilities.wrapError(error, `Error when compiling argument for "${parameterName}"`);
} }
} }

View File

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

View File

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

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

View File

@@ -1,10 +1,11 @@
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';
@@ -23,6 +24,8 @@ 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);
} }
@@ -39,12 +42,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 new ScriptCode( return this.scriptCodeFactory(
compiledCode.code, compiledCode.code,
compiledCode.revertCode, compiledCode.revertCode,
); );
} catch (error) { } catch (error) {
throw Error(`Script "${script.name}" ${error.message}`); throw this.wrapError(error, `Failed to compile script: ${script.name}`);
} }
} }
} }

View File

@@ -4,37 +4,52 @@ 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 { parseDocs } from '../DocumentationParser'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
import { createScriptCode } from '@/domain/ScriptCodeFactory';
import type { IScript } from '@/domain/IScript';
import { parseDocs, type DocsParser } from '../DocumentationParser';
import { createEnumParser, type IEnumParser } from '../../Common/Enum'; import { createEnumParser, type IEnumParser } from '../../Common/Enum';
import { NodeType } from '../NodeValidation/NodeType'; import { NodeDataType } from '../NodeValidation/NodeDataType';
import { NodeValidator } from '../NodeValidation/NodeValidator'; import { createNodeDataValidator, type NodeDataValidator, type NodeDataValidatorFactory } from '../NodeValidation/NodeDataValidator';
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 function parseScript( export interface ScriptParser {
(
data: ScriptData, data: ScriptData,
context: ICategoryCollectionParseContext, context: ICategoryCollectionParseContext,
levelParser = createEnumParser(RecommendationLevel), utilities?: ScriptParserUtilities,
scriptFactory: ScriptFactoryType = ScriptFactory, ): IScript;
codeValidator: ICodeValidator = CodeValidator.instance, }
): Script {
const validator = new NodeValidator({ type: NodeType.Script, selfNode: data }); export const parseScript: ScriptParser = (
data,
context,
utilities = DefaultScriptParserUtilities,
) => {
const validator = utilities.createValidator({
type: NodeDataType.Script,
selfNode: data,
});
validateScript(data, validator); validateScript(data, validator);
try { try {
const script = scriptFactory( const script = utilities.createScript({
/* name: */ data.name, name: data.name,
/* code: */ parseCode(data, context, codeValidator), code: parseCode(data, context, utilities.codeValidator, utilities.createCode),
/* docs: */ parseDocs(data), docs: utilities.parseDocs(data),
/* level: */ parseLevel(data.recommend, levelParser), level: parseLevel(data.recommend, utilities.levelParser),
); });
return script; return script;
} catch (err) { } catch (error) {
return validator.throw(err.message); throw utilities.wrapError(
} error,
validator.createContextualErrorMessage('Failed to parse script.'),
);
} }
};
function parseLevel( function parseLevel(
level: string | undefined, level: string | undefined,
@@ -50,18 +65,19 @@ 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 = new ScriptCode(codeScript.code, codeScript.revertCode); const code = createCode(codeScript.code, codeScript.revertCode);
validateHardcodedCodeWithoutCalls(code, codeValidator, context.syntax); validateHardcodedCodeWithoutCalls(code, codeValidator, context.syntax);
return code; return code;
} }
function validateHardcodedCodeWithoutCalls( function validateHardcodedCodeWithoutCalls(
scriptCode: ScriptCode, scriptCode: IScriptCode,
validator: ICodeValidator, validator: ICodeValidator,
syntax: ILanguageSyntax, syntax: ILanguageSyntax,
) { ) {
@@ -77,25 +93,48 @@ function validateHardcodedCodeWithoutCalls(
function validateScript( function validateScript(
script: ScriptData, script: ScriptData,
validator: NodeValidator, validator: NodeDataValidator,
): asserts script is NonNullable<ScriptData> { ): asserts script is NonNullable<ScriptData> {
validator validator.assertDefined(script);
.assertDefined(script) validator.assertValidName(script.name);
.assertValidName(script.name) validator.assert(
.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.',
) );
.assert( validator.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.',
) );
.assert( validator.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.',
); );
} }
export type ScriptFactoryType = (...parameters: ConstructorParameters<typeof Script>) => Script; interface ScriptParserUtilities {
readonly levelParser: IEnumParser<RecommendationLevel>;
readonly createScript: ScriptFactory;
readonly codeValidator: ICodeValidator;
readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: NodeDataValidatorFactory;
readonly createCode: ScriptCodeFactory;
readonly parseDocs: DocsParser;
}
const ScriptFactory: ScriptFactoryType = (...parameters) => new Script(...parameters); export type ScriptFactory = (
...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,6 +5,7 @@ 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 {
@@ -29,7 +30,9 @@ export class CodeSubstituter implements ICodeSubstituter {
} }
function createSubstituteCompiler(): IExpressionsCompiler { function createSubstituteCompiler(): IExpressionsCompiler {
const parsers = [new ParameterSubstitutionParser()]; const parsers: readonly IExpressionParser[] = [
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 sofware packags 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 software packages 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 sofware packags 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 software packages 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://www.manpagez.com/man/8/spctl/ - https://web.archive.org/web/20240523173608/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,15 +5,21 @@ 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;
constructor( public readonly name: string;
id: number,
public readonly name: string, public readonly docs: ReadonlyArray<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);
validateCategory(this); constructor(parameters: CategoryInitParameters) {
super(parameters.id);
validateParameters(parameters);
this.name = parameters.name;
this.docs = parameters.docs;
this.subCategories = parameters.subcategories;
this.scripts = parameters.scripts;
} }
public includes(script: IScript): boolean { public includes(script: IScript): boolean {
@@ -28,6 +34,14 @@ export class Category extends BaseEntity<number> implements ICategory {
} }
} }
export interface CategoryInitParameters {
readonly id: number;
readonly name: string;
readonly docs: ReadonlyArray<string>;
readonly subcategories: ReadonlyArray<ICategory>;
readonly scripts: ReadonlyArray<IScript>;
}
function parseScriptsRecursively(category: ICategory): ReadonlyArray<IScript> { function parseScriptsRecursively(category: ICategory): ReadonlyArray<IScript> {
return [ return [
...category.scripts, ...category.scripts,
@@ -35,11 +49,11 @@ function parseScriptsRecursively(category: ICategory): ReadonlyArray<IScript> {
]; ];
} }
function validateCategory(category: ICategory) { function validateParameters(parameters: CategoryInitParameters) {
if (!category.name) { if (!parameters.name) {
throw new Error('missing name'); throw new Error('missing name');
} }
if (category.subCategories.length === 0 && category.scripts.length === 0) { if (parameters.subcategories.length === 0 && parameters.scripts.length === 0) {
throw new Error('A category must have at least one sub-category or script'); throw new Error('A category must have at least one sub-category or script');
} }
} }

View File

@@ -4,14 +4,21 @@ 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 {
constructor( public readonly name: string;
public readonly name: string,
public readonly code: IScriptCode, public readonly code: IScriptCode;
public readonly docs: ReadonlyArray<string>,
public readonly level?: RecommendationLevel, public readonly docs: ReadonlyArray<string>;
) {
super(name); public readonly level?: RecommendationLevel;
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 {
@@ -19,6 +26,13 @@ export class Script extends BaseEntity<string> implements IScript {
} }
} }
export interface ScriptInitParameters {
readonly name: string;
readonly code: IScriptCode;
readonly docs: ReadonlyArray<string>;
readonly level?: RecommendationLevel;
}
function validateLevel(level?: RecommendationLevel) { 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

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

View File

@@ -0,0 +1,91 @@
<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,6 +27,7 @@
: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>
@@ -46,6 +47,7 @@ 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: {
@@ -61,8 +63,14 @@ 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;
} }
@@ -101,6 +109,7 @@ export default defineComponent({
width, width,
categoryIds, categoryIds,
activeCategoryId, activeCategoryId,
cardLayout,
onSelected, onSelected,
}; };
}, },

View File

@@ -29,26 +29,12 @@
/> />
</div> </div>
<CardExpandTransition> <CardExpandTransition>
<div v-show="isExpanded"> <CardExpansionPanel
<CardExpansionArrow /> v-show="isExpanded"
<div
class="card__expander"
@click.stop
>
<div class="card__expander__close-button">
<FlatButton
icon="xmark"
@click="collapse()"
/>
</div>
<div class="card__expander__content">
<ScriptsTree
:category-id="categoryId" :category-id="categoryId"
:has-top-padding="false" @on-collapse="collapse"
@click.stop
/> />
</div>
</div>
</div>
</CardExpandTransition> </CardExpandTransition>
</div> </div>
</template> </template>
@@ -56,30 +42,32 @@
<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 CardExpansionArrow from './CardExpansionArrow.vue'; import CardExpansionPanel from './CardExpansionPanel.vue';
import type { CardLayout } from './UseCardLayout';
export default defineComponent({ export default defineComponent({
components: { components: {
ScriptsTree,
AppIcon, AppIcon,
CardSelectionIndicator, CardSelectionIndicator,
FlatButton, CardExpansionPanel,
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,
@@ -129,6 +117,7 @@ export default defineComponent({
cardTitle, cardTitle,
isExpanded, isExpanded,
cardElement, cardElement,
totalColumns: props.cardLayout.totalColumns,
collapse, collapse,
}; };
}, },
@@ -141,7 +130,6 @@ 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 {
@@ -190,44 +178,13 @@ $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 {
@@ -253,36 +210,32 @@ $card-horizontal-gap : $card-gap;
} }
} }
} }
@mixin adaptive-card($cards-in-row) {
&.card { .card {
$total-times-gap-is-used-in-row: $cards-in-row - 1; $total-columns: v-bind(totalColumns);
$total-gap-width-in-row: $total-times-gap-is-used-in-row * $card-horizontal-gap; $total-times-gap-is-used-in-row: calc($total-columns - 1);
$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} / #{$cards-in-row}); $available-width-per-card: calc(#{$available-row-width-for-cards} / $total-columns);
width:$available-width-per-card; width:$available-width-per-card;
.card__expander { :deep(.card__expander) {
$all-cards-width: 100% * $cards-in-row; $all-cards-width: calc(100% * $total-columns);
$additional-padding-width: $card-horizontal-gap * ($cards-in-row - 1); $additional-padding-width: calc($card-horizontal-gap * ($total-columns - 1));
width: calc(#{$all-cards-width} + #{$additional-padding-width}); width: calc(#{$all-cards-width} + #{$additional-padding-width});
} }
@for $nth-card from 2 through $cards-in-row { // From second card to rest // @for $nth-card from 2 through $total-columns { // From second card to rest
&:nth-of-type(#{$cards-in-row}n+#{$nth-card}) { // &:nth-of-type(#{$total-columns}n+#{$nth-card}) {
.card__expander { // :deep(.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: $cards-in-row + 1; $card-after-last: $total-columns + 1;
&:nth-of-type(#{$cards-in-row}n+#{$card-after-last}) { &:nth-of-type(#{$total-columns}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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,21 @@
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', () => {
it(`creates ${ExpressionPosition.name} instance`, () => { describe('it is a transient factory', () => {
// arrange // arrange
const expectedType = ExpressionPosition; const fakeMatch = createRegexMatch();
const fakeMatch = createRegexMatch({
fullMatch: 'matched string',
matchIndex: 5,
});
// act // act
const position = createPositionFromRegexFullMatch(fakeMatch); const create = () => createPositionFromRegexFullMatch(fakeMatch);
// assert // assert
expect(position).to.be.instanceOf(expectedType); itIsTransientFactory({
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;
@@ -63,10 +62,8 @@ 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();
fullMatch: 'matched string', fakeMatch.index = undefined;
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);
@@ -94,9 +91,9 @@ function createRegexMatch(options?: {
readonly capturingGroups?: readonly string[], readonly capturingGroups?: readonly string[],
readonly matchIndex?: number, readonly matchIndex?: number,
}): RegExpMatchArray { }): RegExpMatchArray {
const fullMatch = options?.fullMatch ?? 'fake match'; const fullMatch = options?.fullMatch ?? 'default fake match';
const capturingGroups = options?.capturingGroups ?? []; const capturingGroups = options?.capturingGroups ?? [];
const fakeMatch: RegExpMatchArray = [fullMatch, ...capturingGroups]; const fakeMatch: RegExpMatchArray = [fullMatch, ...capturingGroups];
fakeMatch.index = options?.matchIndex; fakeMatch.index = options?.matchIndex ?? 0;
return fakeMatch; return fakeMatch;
} }

View File

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

View File

@@ -9,7 +9,9 @@ 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 { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError'; import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
describe('NestedFunctionCallCompiler', () => { describe('NestedFunctionCallCompiler', () => {
describe('canCompile', () => { describe('canCompile', () => {
@@ -43,12 +45,12 @@ describe('NestedFunctionCallCompiler', () => {
// arrange // arrange
const argumentCompiler = new ArgumentCompilerStub(); const argumentCompiler = new ArgumentCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub(); const expectedContext = new FunctionCallCompilationContextStub();
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const compiler = new NestedFunctionCallCompilerBuilder() const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompiler) .withArgumentCompiler(argumentCompiler)
.build(); .build();
// act // act
compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext); compiler.compileFunction(frontFunction, 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);
@@ -59,33 +61,37 @@ describe('NestedFunctionCallCompiler', () => {
// arrange // arrange
const argumentCompiler = new ArgumentCompilerStub(); const argumentCompiler = new ArgumentCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub(); const expectedContext = new FunctionCallCompilationContextStub();
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const expectedParentCall = callToFrontFunc;
const compiler = new NestedFunctionCallCompilerBuilder() const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompiler) .withArgumentCompiler(argumentCompiler)
.build(); .build();
// act // act
compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext); compiler.compileFunction(frontFunction, 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(callToFrontFunc); expect(actualParentCall).to.equal(expectedParentCall);
}); });
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 { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); const {
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(frontFunc, callToFrontFunc, expectedContext); compiler.compileFunction(frontFunction, 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(callToFrontFunc); expect(actualNestedCall).to.deep.equal(expectedNestedCall);
}); });
}); });
describe('re-compilation with compiled args', () => { describe('re-compilation with compiled args', () => {
@@ -94,11 +100,11 @@ describe('NestedFunctionCallCompiler', () => {
const singleCallCompilerStub = new SingleCallCompilerStub(); const singleCallCompilerStub = new SingleCallCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub() const expectedContext = new FunctionCallCompilationContextStub()
.withSingleCallCompiler(singleCallCompilerStub); .withSingleCallCompiler(singleCallCompilerStub);
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const compiler = new NestedFunctionCallCompilerBuilder() const compiler = new NestedFunctionCallCompilerBuilder()
.build(); .build();
// act // act
compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext); compiler.compileFunction(frontFunction, 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);
@@ -113,12 +119,12 @@ describe('NestedFunctionCallCompiler', () => {
const singleCallCompilerStub = new SingleCallCompilerStub(); const singleCallCompilerStub = new SingleCallCompilerStub();
const context = new FunctionCallCompilationContextStub() const context = new FunctionCallCompilationContextStub()
.withSingleCallCompiler(singleCallCompilerStub); .withSingleCallCompiler(singleCallCompilerStub);
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const compiler = new NestedFunctionCallCompilerBuilder() const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompilerStub) .withArgumentCompiler(argumentCompilerStub)
.build(); .build();
// act // act
compiler.compileFunction(frontFunc, callToFrontFunc, context); compiler.compileFunction(frontFunction, 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);
@@ -140,9 +146,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 frontFunc = createSharedFunctionStubWithCalls() const frontFunction = createSharedFunctionStubWithCalls()
.withCalls(callToDeepFunc1, callToDeepFunc2); .withCalls(callToDeepFunc1, callToDeepFunc2);
const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunc.name); const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunction.name);
const singleCallCompilerStub = new SingleCallCompilerStub() const singleCallCompilerStub = new SingleCallCompilerStub()
.withCallCompilationScenarios(singleCallCompilationScenario); .withCallCompilationScenarios(singleCallCompilationScenario);
const expectedContext = new FunctionCallCompilationContextStub() const expectedContext = new FunctionCallCompilationContextStub()
@@ -151,73 +157,105 @@ describe('NestedFunctionCallCompiler', () => {
.withArgumentCompiler(argumentCompiler) .withArgumentCompiler(argumentCompiler)
.build(); .build();
// act // act
const actualCodes = compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext); const actualCodes = compiler.compileFunction(frontFunction, 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', () => {
it('handles argument compiler errors', () => { describe('rethrows error from argument compiler', () => {
// arrange // arrange
const argumentCompilerError = new Error('Test error'); const expectedInnerError = new Error(`Expected error from ${ArgumentCompilerStub.name}`);
const calleeFunctionName = 'expectedCalleeFunctionName';
const callerFunctionName = 'expectedCallerFunctionName';
const expectedErrorMessage = buildRethrowErrorMessage({
callee: calleeFunctionName,
caller: callerFunctionName,
});
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc({
frontFunctionName: callerFunctionName,
deepFunctionName: calleeFunctionName,
});
const argumentCompilerStub = new ArgumentCompilerStub(); const argumentCompilerStub = new ArgumentCompilerStub();
argumentCompilerStub.createCompiledNestedCall = () => { argumentCompilerStub.createCompiledNestedCall = () => {
throw argumentCompilerError; throw expectedInnerError;
}; };
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); const builder = new NestedFunctionCallCompilerBuilder()
const expectedError = new AggregateError( .withArgumentCompiler(argumentCompilerStub);
[argumentCompilerError], itThrowsContextualError({
`Error with call to "${callToFrontFunc.functionName}" function from "${callToFrontFunc.functionName}" function`,
);
const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompilerStub)
.build();
// act // act
const act = () => compiler.compileFunction( throwingAction: (wrapError) => {
frontFunc, builder
.withErrorWrapper(wrapError)
.build()
.compileFunction(
frontFunction,
callToFrontFunc, callToFrontFunc,
new FunctionCallCompilationContextStub(), new FunctionCallCompilationContextStub(),
); );
},
// assert // assert
expectDeepThrowsError(act, expectedError); expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
}); });
it('handles single call compiler errors', () => { });
describe('rethrows error from single call compiler', () => {
// arrange // arrange
const singleCallCompilerError = new Error('Test error'); const expectedInnerError = new Error(`Expected error from ${SingleCallCompilerStub.name}`);
const calleeFunctionName = 'expectedCalleeFunctionName';
const callerFunctionName = 'expectedCallerFunctionName';
const expectedErrorMessage = buildRethrowErrorMessage({
callee: calleeFunctionName,
caller: callerFunctionName,
});
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc({
frontFunctionName: callerFunctionName,
deepFunctionName: calleeFunctionName,
});
const singleCallCompiler = new SingleCallCompilerStub(); const singleCallCompiler = new SingleCallCompilerStub();
singleCallCompiler.compileSingleCall = () => { singleCallCompiler.compileSingleCall = () => {
throw singleCallCompilerError; throw expectedInnerError;
}; };
const context = new FunctionCallCompilationContextStub() const context = new FunctionCallCompilationContextStub()
.withSingleCallCompiler(singleCallCompiler); .withSingleCallCompiler(singleCallCompiler);
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); const builder = new NestedFunctionCallCompilerBuilder();
const expectedError = new AggregateError( itThrowsContextualError({
[singleCallCompilerError],
`Error with call to "${callToFrontFunc.functionName}" function from "${callToFrontFunc.functionName}" function`,
);
const compiler = new NestedFunctionCallCompilerBuilder()
.build();
// act // act
const act = () => compiler.compileFunction( throwingAction: (wrapError) => {
frontFunc, builder
.withErrorWrapper(wrapError)
.build()
.compileFunction(
frontFunction,
callToFrontFunc, callToFrontFunc,
context, context,
); );
},
// assert // assert
expectDeepThrowsError(act, expectedError); expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
}); });
}); });
}); });
}); });
function createSingleFuncCallingAnotherFunc() { function createSingleFuncCallingAnotherFunc(
const deepFunc = createSharedFunctionStubWithCode(); functionNames?: {
const callToDeepFunc = new FunctionCallStub().withFunctionName(deepFunc.name); readonly frontFunctionName?: string;
const frontFunc = createSharedFunctionStubWithCalls().withCalls(callToDeepFunc); readonly deepFunctionName?: string;
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 {
deepFunc, deepFunction,
frontFunc, frontFunction,
callToFrontFunc, callToFrontFunc,
callToDeepFunc, callToDeepFunc,
}; };
@@ -226,14 +264,31 @@ 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,6 +11,7 @@ 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', () => {
@@ -28,40 +29,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".`
+ '. Expected parameter(s): "expected-parameter"', + '\nExpected 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".`
+ '. Expected parameter(s): "expected-parameter"', + '\nExpected 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".`
+ '. Expected parameter(s): "expected-parameter1", "expected-parameter2"', + '\nExpected 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".`
+ '. Expected parameter(s): none', + '\nExpected 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".`
+ '. Expected parameter(s): "expected-parameter"', + '\nExpected parameter(s): "expected-parameter"',
}, },
]; ];
testCases.forEach(({ testCases.forEach(({
@@ -88,7 +89,8 @@ describe('AdaptiveFunctionCallCompiler', () => {
// act // act
const act = () => builder.compileSingleCall(); const act = () => builder.compileSingleCall();
// assert // assert
expect(act).to.throw(expectedError); const errorMessage = collectExceptionMessage(act);
expect(errorMessage).to.include(expectedError);
}); });
}); });
}); });

View File

@@ -7,38 +7,44 @@ 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', () => {
it('should handle error from expressions compiler', () => { describe('rethrows 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 expressionsCompilerError; }; expressionsCompiler.compileExpressions = () => { throw expectedInnerError; };
const builder = new NestedFunctionArgumentCompilerBuilder() const builder = new NestedFunctionArgumentCompilerBuilder()
.withParentFunctionCall(parentCall) .withParentFunctionCall(parentCall)
.withNestedFunctionCall(nestedCall) .withNestedFunctionCall(nestedCall)
.withExpressionsCompiler(expressionsCompiler); .withExpressionsCompiler(expressionsCompiler);
itThrowsContextualError({
// act // act
const act = () => builder.createCompiledNestedCall(); throwingAction: (wrapError) => {
builder
.withErrorWrapper(wrapError)
.createCompiledNestedCall();
},
// assert // assert
expectDeepThrowsError(act, expectedError); expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
}); });
describe('compilation', () => { describe('compilation', () => {
describe('without arguments', () => { describe('without arguments', () => {
@@ -258,6 +264,8 @@ 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;
@@ -278,8 +286,16 @@ 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(this.expressionsCompiler); const compiler = new NestedFunctionArgumentCompiler(
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').withOptionality(true), new FunctionParameterStub().withName('2').withOptional(true),
new FunctionParameterStub().withName('3').withOptionality(false), new FunctionParameterStub().withName('3').withOptional(false),
]; ];
const sut = new FunctionParameterCollection(); const sut = new FunctionParameterCollection();
for (const parameter of expected) { for (const parameter of expected) {

View File

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

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

View File

@@ -1,9 +1,7 @@
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';
@@ -17,8 +15,13 @@ 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', () => {
@@ -58,11 +61,30 @@ describe('ScriptCompiler', () => {
// assert // assert
expect(act).to.throw(expectedError); expect(act).to.throw(expectedError);
}); });
it('returns code as expected', () => { describe('code construction', () => {
it('returns code from the factory', () => {
// arrange // arrange
const expected: CompiledCode = { const expectedCode = new ScriptCodeStub();
code: 'expected-code', const scriptCodeFactory = () => expectedCode;
revertCode: 'expected-revert-code', const sut = new ScriptCompilerBuilder()
.withSomeFunctions()
.withScriptCodeFactory(scriptCodeFactory)
.build();
// act
const actualCode = sut.compile(createScriptDataWithCall());
// assert
expect(actualCode).to.equal(expectedCode);
});
it('creates code correctly', () => {
// arrange
const expectedCode = 'expected-code';
const expectedRevertCode = 'expected-revert-code';
let actualCode: string | undefined;
let actualRevertCode: string | undefined;
const scriptCodeFactory = (code: string, revertCode: string) => {
actualCode = code;
actualRevertCode = revertCode;
return new ScriptCodeStub();
}; };
const call = new FunctionCallDataStub(); const call = new FunctionCallDataStub();
const script = createScriptDataWithCall(call); const script = createScriptDataWithCall(call);
@@ -71,18 +93,27 @@ 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(parseFunctionCalls(call), compiledFunctions, expected); callCompilerMock.setup(
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
const code = sut.compile(script); sut.compile(script);
// assert // assert
expect(code.execute).to.equal(expected.code); expect(actualCode).to.equal(expectedCode);
expect(code.revert).to.equal(expected.revertCode); expect(actualRevertCode).to.equal(expectedRevertCode);
}); });
});
describe('parses functions as expected', () => { describe('parses functions as expected', () => {
it('parses functions with expected syntax', () => { it('parses functions with expected syntax', () => {
// arrange // arrange
@@ -116,49 +147,57 @@ describe('ScriptCompiler', () => {
expect(parser.callHistory[0].functions).to.deep.equal(expectedFunctions); expect(parser.callHistory[0].functions).to.deep.equal(expectedFunctions);
}); });
}); });
it('rethrows error with script name', () => { describe('rethrows error with script name', () => {
// arrange // arrange
const scriptName = 'scriptName'; const scriptName = 'scriptName';
const innerError = 'innerError'; const expectedErrorMessage = `Failed to compile script: ${scriptName}`;
const expectedError = `Script "${scriptName}" ${innerError}`; const expectedInnerError = new Error();
const callCompiler: FunctionCallCompiler = { const callCompiler: FunctionCallCompiler = {
compileFunctionCalls: () => { throw new Error(innerError); }, compileFunctionCalls: () => { throw expectedInnerError; },
}; };
const scriptData = createScriptDataWithCall() const scriptData = createScriptDataWithCall()
.withName(scriptName); .withName(scriptName);
const sut = new ScriptCompilerBuilder() const builder = new ScriptCompilerBuilder()
.withSomeFunctions() .withSomeFunctions()
.withFunctionCallCompiler(callCompiler) .withFunctionCallCompiler(callCompiler);
.build(); itThrowsContextualError({
// act // act
const act = () => sut.compile(scriptData); throwingAction: (wrapError) => {
builder
.withErrorWrapper(wrapError)
.build()
.compile(scriptData);
},
// assert // assert
expect(act).to.throw(expectedError); expectedWrappedError: expectedInnerError,
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 syntax = new LanguageSyntaxStub(); const expectedErrorMessage = `Failed to compile script: ${scriptName}`;
const invalidCode = new CompiledCodeStub() const expectedInnerError = new Error();
.withCode('' /* invalid code (empty string) */); const scriptCodeFactory: ScriptCodeFactory = () => {
const realExceptionMessage = collectExceptionMessage( throw expectedInnerError;
() => 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 sut = new ScriptCompilerBuilder() const builder = new ScriptCompilerBuilder()
.withSomeFunctions() .withSomeFunctions()
.withFunctionCallCompiler(callCompiler) .withScriptCodeFactory(scriptCodeFactory);
.withSyntax(syntax) itThrowsContextualError({
.build();
// act // act
const act = () => sut.compile(scriptData); throwingAction: (wrapError) => {
builder
.withErrorWrapper(wrapError)
.build()
.compile(scriptData);
},
// assert // assert
expect(act).to.throw(expectedError); expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
}); });
it('validates compiled code as expected', () => { it('validates compiled code as expected', () => {
// arrange // arrange
@@ -166,17 +205,27 @@ 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
const compilationResult = sut.compile(scriptData); sut.compile(scriptData);
// assert // assert
validator.assertHistory({ validator.assertHistory({
validatedCodes: [compilationResult.execute, compilationResult.revert], validatedCodes: [expectedExecuteCode, expectedRevertCode],
rules: expectedRules, rules: expectedRules,
}); });
}); });
@@ -200,6 +249,12 @@ 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;
@@ -244,6 +299,16 @@ 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');
@@ -254,6 +319,8 @@ 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 ScriptFactoryType } from '@/application/Parser/Script/ScriptParser'; import { parseScript, type ScriptFactory } from '@/application/Parser/Script/ScriptParser';
import { parseDocs } from '@/application/Parser/DocumentationParser'; import { type DocsParser } 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,54 +11,88 @@ 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 as expected', () => { it('parses name correctly', () => {
// arrange // arrange
const expected = 'test-expected-name'; const expected = 'test-expected-name';
const script = createScriptDataWithCode() const scriptData = createScriptDataWithCode()
.withName(expected); .withName(expected);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act // act
const actual = new TestBuilder() const actualScript = new TestContext()
.withData(script) .withData(scriptData)
.withScriptFactory(scriptFactorySpy)
.parseScript(); .parseScript();
// assert // assert
expect(actual.name).to.equal(expected); const actualName = getInitParameters(actualScript)?.name;
expect(actualName).to.equal(expected);
}); });
it('parses docs as expected', () => { it('parses docs correctly', () => {
// arrange // arrange
const docs = ['https://expected-doc1.com', 'https://expected-doc2.com']; const expectedDocs = ['https://expected-doc1.com', 'https://expected-doc2.com'];
const script = createScriptDataWithCode() const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
.withDocs(docs); const scriptData = createScriptDataWithCode()
const expected = parseDocs(script); .withDocs(expectedDocs);
const docsParser: DocsParser = (data) => data.docs as typeof expectedDocs;
// act // act
const actual = new TestBuilder() const actualScript = new TestContext()
.withData(script) .withData(scriptData)
.withScriptFactory(scriptFactorySpy)
.withDocsParser(docsParser)
.parseScript(); .parseScript();
// assert // assert
expect(actual.docs).to.deep.equal(expected); const actualDocs = getInitParameters(actualScript)?.docs;
expect(actualDocs).to.deep.equal(expectedDocs);
});
it('gets script from the factory', () => {
// arrange
const expectedScript = new ScriptStub('expected-script');
const scriptFactory: ScriptFactory = () => expectedScript;
// act
const actualScript = new TestContext()
.withScriptFactory(scriptFactory)
.parseScript();
// assert
expect(actualScript).to.equal(expectedScript);
}); });
describe('level', () => { describe('level', () => {
describe('accepts absent level', () => { describe('generated `undefined` level if given absent value', () => {
itEachAbsentStringValue((absentValue) => { itEachAbsentStringValue((absentValue) => {
// arrange // arrange
const script = createScriptDataWithCode() const expectedLevel = undefined;
const scriptData = createScriptDataWithCode()
.withRecommend(absentValue); .withRecommend(absentValue);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act // act
const actual = new TestBuilder() const actualScript = new TestContext()
.withData(script) .withData(scriptData)
.withScriptFactory(scriptFactorySpy)
.parseScript(); .parseScript();
// assert // assert
expect(actual.level).to.equal(undefined); const actualLevel = getInitParameters(actualScript)?.level;
expect(actualLevel).to.equal(expectedLevel);
}, { excludeNull: true }); }, { excludeNull: true });
}); });
it('parses level as expected', () => { it('parses level as expected', () => {
@@ -66,63 +100,94 @@ describe('ScriptParser', () => {
const expectedLevel = RecommendationLevel.Standard; const expectedLevel = RecommendationLevel.Standard;
const expectedName = 'level'; const expectedName = 'level';
const levelText = 'standard'; const levelText = 'standard';
const script = createScriptDataWithCode() const scriptData = 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 actual = new TestBuilder() const actualScript = new TestContext()
.withData(script) .withData(scriptData)
.withParser(parserMock) .withParser(parserMock)
.withScriptFactory(scriptFactorySpy)
.parseScript(); .parseScript();
// assert // assert
expect(actual.level).to.equal(expectedLevel); const actualLevel = getInitParameters(actualScript)?.level;
expect(actualLevel).to.equal(expectedLevel);
}); });
}); });
describe('code', () => { describe('code', () => {
it('parses "execute" as expected', () => { it('creates from script code factory', () => {
// arrange // arrange
const expected = 'expected-code'; const expectedCode = new ScriptCodeStub();
const script = createScriptDataWithCode() const scriptCodeFactory: ScriptCodeFactory = () => expectedCode;
.withCode(expected); const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act // act
const parsed = new TestBuilder() const actualScript = new TestContext()
.withData(script) .withScriptCodeFactory(scriptCodeFactory)
.withScriptFactory(scriptFactorySpy)
.parseScript(); .parseScript();
// assert // assert
const actual = parsed.code.execute; const actualCode = getInitParameters(actualScript)?.code;
expect(actual).to.equal(expected); expect(expectedCode).to.equal(actualCode);
});
describe('parses code correctly', () => {
it('parses "execute" as expected', () => {
// arrange
const expectedCode = 'expected-code';
let actualCode: string | undefined;
const scriptCodeFactory: ScriptCodeFactory = (code) => {
actualCode = code;
return new ScriptCodeStub();
};
const scriptData = createScriptDataWithCode()
.withCode(expectedCode);
// act
new TestContext()
.withData(scriptData)
.withScriptCodeFactory(scriptCodeFactory)
.parseScript();
// assert
expect(actualCode).to.equal(expectedCode);
}); });
it('parses "revert" as expected', () => { it('parses "revert" as expected', () => {
// arrange // arrange
const expected = 'expected-revert-code'; const expectedRevertCode = 'expected-revert-code';
const script = createScriptDataWithCode() const scriptData = createScriptDataWithCode()
.withRevertCode(expected); .withRevertCode(expectedRevertCode);
let actualRevertCode: string | undefined;
const scriptCodeFactory: ScriptCodeFactory = (_, revertCode) => {
actualRevertCode = revertCode;
return new ScriptCodeStub();
};
// act // act
const parsed = new TestBuilder() new TestContext()
.withData(script) .withData(scriptData)
.withScriptCodeFactory(scriptCodeFactory)
.parseScript(); .parseScript();
// assert // assert
const actual = parsed.code.revert; expect(actualRevertCode).to.equal(expectedRevertCode);
expect(actual).to.equal(expected); });
}); });
describe('compiler', () => { describe('compiler', () => {
it('gets code from compiler', () => { it('compiles the code through the compiler', () => {
// arrange // arrange
const expected = new ScriptCodeStub(); const expectedCode = new ScriptCodeStub();
const script = createScriptDataWithCode(); const script = createScriptDataWithCode();
const compiler = new ScriptCompilerStub() const compiler = new ScriptCompilerStub()
.withCompileAbility(script, expected); .withCompileAbility(script, expectedCode);
const parseContext = new CategoryCollectionParseContextStub() const parseContext = new CategoryCollectionParseContextStub()
.withCompiler(compiler); .withCompiler(compiler);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act // act
const parsed = new TestBuilder() const actualScript = new TestContext()
.withData(script) .withData(script)
.withContext(parseContext) .withContext(parseContext)
.withScriptFactory(scriptFactorySpy)
.parseScript(); .parseScript();
// assert // assert
const actual = parsed.code; const actualCode = getInitParameters(actualScript)?.code;
expect(actual).to.equal(expected); expect(actualCode).to.equal(expectedCode);
}); });
}); });
describe('syntax', () => { describe('syntax', () => {
@@ -135,7 +200,7 @@ describe('ScriptParser', () => {
const script = createScriptDataWithoutCallOrCodes() const script = createScriptDataWithoutCallOrCodes()
.withCode(duplicatedCode); .withCode(duplicatedCode);
// act // act
const act = () => new TestBuilder() const act = () => new TestContext()
.withData(script) .withData(script)
.withContext(parseContext); .withContext(parseContext);
// assert // assert
@@ -149,18 +214,26 @@ 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 script = createScriptDataWithCode() const scriptCodeFactory = createScriptCodeFactoryStub({
.withCode('expected code to be validated') scriptCode: new ScriptCodeStub()
.withRevertCode('expected revert code to be validated'); .withExecute(expectedCode)
.withRevert(expectedRevertCode),
});
// act // act
new TestBuilder() new TestContext()
.withData(script) .withScriptCodeFactory(scriptCodeFactory)
.withCodeValidator(validator) .withCodeValidator(validator)
.parseScript(); .parseScript();
// assert // assert
validator.assertHistory({ validator.assertHistory({
validatedCodes: [script.code, script.revertCode], validatedCodes: expectedCodeCalls,
rules: expectedRules, rules: expectedRules,
}); });
}); });
@@ -175,7 +248,7 @@ describe('ScriptParser', () => {
const parseContext = new CategoryCollectionParseContextStub() const parseContext = new CategoryCollectionParseContextStub()
.withCompiler(compiler); .withCompiler(compiler);
// act // act
new TestBuilder() new TestContext()
.withData(script) .withData(script)
.withCodeValidator(validator) .withCodeValidator(validator)
.withContext(parseContext) .withContext(parseContext)
@@ -188,111 +261,250 @@ describe('ScriptParser', () => {
}); });
}); });
}); });
describe('invalid script data', () => { describe('validation', () => {
describe('validates script data', () => { describe('validates for name', () => {
// arrange // arrange
const createTest = (script: ScriptData): ITestScenario => ({ const expectedName = 'expected script name to be validated';
act: () => new TestBuilder() const script = createScriptDataWithCall()
.withData(script) .withName(expectedName);
.parseScript(), const expectedContext: ScriptNodeErrorContext = {
expectedContext: { type: NodeDataType.Script,
type: NodeType.Script,
selfNode: script, selfNode: script,
}, };
}); itValidatesName((validatorFactory) => {
// act and assert
new NodeValidationTestRunner()
.testInvalidNodeName((invalidName) => {
return createTest(
createScriptDataWithCall().withName(invalidName),
);
})
.testMissingNodeData((node) => {
return createTest(node as ScriptData);
})
.runThrowingCase({
name: 'throws when both function call and code are defined',
scenario: createTest(
createScriptDataWithCall().withCode('code'),
),
expectedMessage: 'Both "call" and "code" are defined.',
})
.runThrowingCase({
name: 'throws when both function call and revertCode are defined',
scenario: createTest(
createScriptDataWithCall().withRevertCode('revert-code'),
),
expectedMessage: 'Both "call" and "revertCode" are defined.',
})
.runThrowingCase({
name: 'throws when neither call or revertCode are defined',
scenario: createTest(
createScriptDataWithoutCallOrCodes(),
),
expectedMessage: 'Neither "call" or "code" is defined.',
});
});
it(`rethrows exception if ${Script.name} cannot be constructed`, () => {
// arrange
const expectedError = 'script creation failed';
const factoryMock: ScriptFactoryType = () => { throw new Error(expectedError); };
const data = createScriptDataWithCode();
// act // act
const act = () => new TestBuilder() new TestContext()
.withData(data) .withData(script)
.withFactory(factoryMock) .withValidatorFactory(validatorFactory)
.parseScript(); .parseScript();
// expect // assert
expectThrowsNodeError({ return {
act, expectedNameToValidate: expectedName,
expectedContext: { expectedErrorContext: expectedContext,
type: NodeType.Script, };
selfNode: data, });
});
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,
};
}, },
}, expectedError); );
});
describe('validates data', () => {
// arrange
const testScenarios = generateDataValidationTestScenarios<ScriptData>(
{
assertErrorMessage: 'Neither "call" or "code" is defined.',
expectFail: [{
description: 'with no call or code',
data: createScriptDataWithoutCallOrCodes(),
}],
expectPass: [
{
description: 'with call',
data: createScriptDataWithCall(),
},
{
description: 'with code',
data: createScriptDataWithCode(),
},
],
},
{
assertErrorMessage: 'Both "call" and "revertCode" are defined.',
expectFail: [{
description: 'with both call and revertCode',
data: createScriptDataWithCall()
.withRevertCode('revert-code'),
}],
expectPass: [
{
description: 'with call, without revertCode',
data: createScriptDataWithCall()
.withRevertCode(undefined),
},
{
description: 'with revertCode, without call',
data: createScriptDataWithCode()
.withRevertCode('revert code'),
},
],
},
{
assertErrorMessage: 'Both "call" and "code" are defined.',
expectFail: [{
description: 'with both call and code',
data: createScriptDataWithCall()
.withCode('code'),
}],
expectPass: [
{
description: 'with call, without code',
data: createScriptDataWithCall()
.withCode(''),
},
{
description: 'with code, without call',
data: createScriptDataWithCode()
.withCode('code'),
},
],
},
);
testScenarios.forEach(({
description, expectedPass, data: scriptData, expectedMessage,
}) => {
describe(description, () => {
itAsserts({
expectedConditionResult: expectedPass,
test: (validatorFactory) => {
const expectedContext: ScriptNodeErrorContext = {
type: NodeDataType.Script,
selfNode: scriptData,
};
// act
new TestContext()
.withData(scriptData)
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
expectExists(expectedMessage);
return {
expectedErrorMessage: expectedMessage,
expectedErrorContext: expectedContext,
};
},
});
});
});
});
});
describe('rethrows exception if script factory fails', () => {
// arrange
const givenData = createScriptDataWithCode();
const expectedContextMessage = 'Failed to parse script.';
const expectedError = new Error();
const validatorFactory: NodeDataValidatorFactory = () => {
const validatorStub = new NodeDataValidatorStub();
validatorStub.createContextualErrorMessage = (message) => message;
return validatorStub;
};
// act & assert
itThrowsContextualError({
throwingAction: (wrapError) => {
const factoryMock: ScriptFactory = () => {
throw expectedError;
};
new TestContext()
.withScriptFactory(factoryMock)
.withErrorWrapper(wrapError)
.withValidatorFactory(validatorFactory)
.withData(givenData)
.parseScript();
},
expectedWrappedError: expectedError,
expectedContextMessage,
}); });
}); });
}); });
}); });
class TestBuilder { class TestContext {
private data: ScriptData = createScriptDataWithCode(); private data: ScriptData = createScriptDataWithCode();
private context: ICategoryCollectionParseContext = new CategoryCollectionParseContextStub(); private context: ICategoryCollectionParseContext = new CategoryCollectionParseContextStub();
private parser: IEnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>() private levelParser: IEnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>()
.setupDefaultValue(RecommendationLevel.Standard); .setupDefaultValue(RecommendationLevel.Standard);
private factory?: ScriptFactoryType = undefined; private scriptFactory: ScriptFactory = createScriptFactorySpy().scriptFactorySpy;
private codeValidator: ICodeValidator = new CodeValidatorStub(); private codeValidator: ICodeValidator = new CodeValidatorStub();
public withCodeValidator(codeValidator: ICodeValidator) { private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get();
private validatorFactory: NodeDataValidatorFactory = createNodeDataValidatorFactoryStub;
private docsParser: DocsParser = () => ['docs'];
private scriptCodeFactory: ScriptCodeFactory = createScriptCodeFactoryStub({
defaultCodePrefix: TestContext.name,
});
public withCodeValidator(codeValidator: ICodeValidator): this {
this.codeValidator = codeValidator; this.codeValidator = codeValidator;
return this; return this;
} }
public withData(data: ScriptData) { public withData(data: ScriptData): this {
this.data = data; this.data = data;
return this; return this;
} }
public withContext(context: ICategoryCollectionParseContext) { public withContext(context: ICategoryCollectionParseContext): this {
this.context = context; this.context = context;
return this; return this;
} }
public withParser(parser: IEnumParser<RecommendationLevel>) { public withParser(parser: IEnumParser<RecommendationLevel>): this {
this.parser = parser; this.levelParser = parser;
return this; return this;
} }
public withFactory(factory: ScriptFactoryType) { public withScriptFactory(scriptFactory: ScriptFactory): this {
this.factory = factory; this.scriptFactory = scriptFactory;
return this;
}
public withValidatorFactory(validatorFactory: NodeDataValidatorFactory): this {
this.validatorFactory = validatorFactory;
return this;
}
public withErrorWrapper(errorWrapper: ErrorWithContextWrapper): this {
this.errorWrapper = errorWrapper;
return this;
}
public withScriptCodeFactory(scriptCodeFactory: ScriptCodeFactory): this {
this.scriptCodeFactory = scriptCodeFactory;
return this;
}
public withDocsParser(docsParser: DocsParser): this {
this.docsParser = docsParser;
return this; return this;
} }
public parseScript(): Script { public parseScript(): Script {
return parseScript(this.data, this.context, this.parser, this.factory, this.codeValidator); return parseScript(
this.data,
this.context,
{
levelParser: this.levelParser,
createScript: this.scriptFactory,
codeValidator: this.codeValidator,
wrapError: this.errorWrapper,
createValidator: this.validatorFactory,
createCode: this.scriptCodeFactory,
parseDocs: this.docsParser,
},
);
} }
} }

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 { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests'; import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests';
import type { ICodeLine } from '@/application/Parser/Script/Validation/ICodeLine'; import type { 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', () => {
itIsSingleton({ itIsSingletonFactory({
getter: () => CodeValidator.instance, getter: () => CodeValidator.instance,
expectedType: CodeValidator, expectedType: CodeValidator,
}); });

View File

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

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('sets as expected', () => { it('assigns code correctly', () => {
// 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('cannot construct with invalid wrong value', () => { it('throws when constructed with invalid level', () => {
// 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('sets undefined as expected', () => { it('handles undefined level correctly', () => {
// 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('sets as expected', () => { it('correctly assigns valid recommendation levels', () => {
// 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('sets as expected', () => { it('correctly assigns docs', () => {
// 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({
this.name, name: this.name,
this.code, code: this.code,
this.docs, docs: this.docs,
this.level, level: this.level,
); });
} }
} }

View File

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

View File

@@ -1,7 +1,7 @@
import { import {
describe, describe,
} from 'vitest'; } from 'vitest';
import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests'; import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests';
import { EnvironmentVariablesFactory, type EnvironmentVariablesValidator } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory'; import { 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', () => {
itIsSingleton({ itIsSingletonFactory({
getter: () => EnvironmentVariablesFactory.Current.instance, getter: () => EnvironmentVariablesFactory.Current.instance,
expectedType: ViteEnvironmentVariables, expectedType: ViteEnvironmentVariables,
}); });

View File

@@ -3,8 +3,9 @@ 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 { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests'; import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests';
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', () => {
@@ -46,19 +47,22 @@ function createTransientTests() {
const registeredObject = api.inject(injectionKey); const registeredObject = api.inject(injectionKey);
expect(registeredObject).to.be.instanceOf(Function); expect(registeredObject).to.be.instanceOf(Function);
}); });
it('should return different instances for transient dependency', () => { describe('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();
// expect // act
const getFactoryResult = () => {
const registeredObject = api.inject(injectionKey); const registeredObject = api.inject(injectionKey);
const factory = registeredObject as () => unknown; const factory = registeredObject as () => unknown;
const firstResult = factory(); return factory();
const secondResult = factory(); };
expect(firstResult).to.not.equal(secondResult); // assert
itIsTransientFactory({
getter: getFactoryResult,
});
}); });
}; };
} }
@@ -87,7 +91,7 @@ function createSingletonTests() {
// act // act
const getRegisteredInstance = () => api.inject(injectionKey); const getRegisteredInstance = () => api.inject(injectionKey);
// assert // assert
itIsSingleton({ itIsSingletonFactory({
getter: getRegisteredInstance, getter: getRegisteredInstance,
}); });
}); });

View File

@@ -6,8 +6,7 @@ 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 { NodeType } from '@/application/Parser/NodeValidation/NodeType'; import { type NodeMetadata, NodeType } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
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 { NodeType } from '@/application/Parser/NodeValidation/NodeType'; import { NodeDataType } from '@/application/Parser/NodeValidation/NodeDataType';
import type { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; import 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(NodeType.Category, getErrorMessage('type')); expect(node.type).to.equal(NodeDataType.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(NodeType.Script, getErrorMessage('type')); expect(node.type).to.equal(NodeDataType.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 { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests'; import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests';
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', () => {
itIsSingleton({ itIsSingletonFactory({
getter: () => ClientLoggerFactory.Current, getter: () => ClientLoggerFactory.Current,
expectedType: ClientLoggerFactory, expectedType: ClientLoggerFactory,
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 = 'default execute code'; public execute = `[${ScriptCodeStub.name}] default execute code`;
public revert = 'default revert code'; public revert = `[${ScriptCodeStub.name}] default revert code`;
public withExecute(code: string) { public withExecute(code: string) {
this.execute = code; this.execute = code;

View File

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

View File

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

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 ISingletonTestData<T> { interface SingletonTestData<T> {
readonly getter: () => T; readonly getter: () => T;
readonly expectedType?: Constructible<T>; readonly expectedType?: Constructible<T>;
} }
export function itIsSingleton<T>(test: ISingletonTestData<T>): void { export function itIsSingletonFactory<T>(test: SingletonTestData<T>): void {
if (test.expectedType !== undefined) { if (test.expectedType !== undefined) {
it('gets the expected type', () => { it('gets the expected type', () => {
// act // act

View File

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