Improve script/category name validation
- Use better error messages with more context. - Unify their validation logic and share tests. - Validate also type of the name. - Refactor node (Script/Category) parser tests for easier future changes and cleaner test code (using `TestBuilder` to do dirty work in unified way). - Add more tests. Custom `Error` properties are compared manually due to `chai` not supporting deep equality checks (chaijs/chai#1065, chaijs/chai#1405).
This commit is contained in:
@@ -3,6 +3,8 @@ 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 { NodeType } from '@/application/Parser/NodeValidation/NodeType';
|
||||||
import { parseDocUrls } from './DocumentationParser';
|
import { parseDocUrls } from './DocumentationParser';
|
||||||
import { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext';
|
import { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext';
|
||||||
import { parseScript } from './Script/ScriptParser';
|
import { parseScript } from './Script/ScriptParser';
|
||||||
@@ -12,35 +14,67 @@ let categoryIdCounter = 0;
|
|||||||
export function parseCategory(
|
export function parseCategory(
|
||||||
category: CategoryData,
|
category: CategoryData,
|
||||||
context: ICategoryCollectionParseContext,
|
context: ICategoryCollectionParseContext,
|
||||||
|
factory: CategoryFactoryType = CategoryFactory,
|
||||||
): Category {
|
): Category {
|
||||||
if (!context) { throw new Error('missing context'); }
|
if (!context) { throw new Error('missing context'); }
|
||||||
ensureValid(category);
|
return parseCategoryRecursively({
|
||||||
|
categoryData: category,
|
||||||
|
context,
|
||||||
|
factory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ICategoryParseContext {
|
||||||
|
readonly categoryData: CategoryData,
|
||||||
|
readonly context: ICategoryCollectionParseContext,
|
||||||
|
readonly factory: CategoryFactoryType,
|
||||||
|
readonly parentCategory?: CategoryData,
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line consistent-return
|
||||||
|
function parseCategoryRecursively(context: ICategoryParseContext): Category {
|
||||||
|
ensureValidCategory(context.categoryData, context.parentCategory);
|
||||||
const children: ICategoryChildren = {
|
const children: ICategoryChildren = {
|
||||||
subCategories: new Array<Category>(),
|
subCategories: new Array<Category>(),
|
||||||
subScripts: new Array<Script>(),
|
subScripts: new Array<Script>(),
|
||||||
};
|
};
|
||||||
for (const data of category.children) {
|
for (const data of context.categoryData.children) {
|
||||||
parseCategoryChild(data, children, category, context);
|
parseNode({
|
||||||
|
nodeData: data,
|
||||||
|
children,
|
||||||
|
parent: context.categoryData,
|
||||||
|
factory: context.factory,
|
||||||
|
context: context.context,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return new Category(
|
try {
|
||||||
|
return context.factory(
|
||||||
/* id: */ categoryIdCounter++,
|
/* id: */ categoryIdCounter++,
|
||||||
/* name: */ category.category,
|
/* name: */ context.categoryData.category,
|
||||||
/* docs: */ parseDocUrls(category),
|
/* docs: */ parseDocUrls(context.categoryData),
|
||||||
/* categories: */ children.subCategories,
|
/* categories: */ children.subCategories,
|
||||||
/* scripts: */ children.subScripts,
|
/* scripts: */ children.subScripts,
|
||||||
);
|
);
|
||||||
|
} catch (err) {
|
||||||
|
new NodeValidator({
|
||||||
|
type: NodeType.Category,
|
||||||
|
selfNode: context.categoryData,
|
||||||
|
parentNode: context.parentCategory,
|
||||||
|
}).throw(err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureValid(category: CategoryData) {
|
function ensureValidCategory(category: CategoryData, parentCategory?: CategoryData) {
|
||||||
if (!category) {
|
new NodeValidator({
|
||||||
throw Error('missing category');
|
type: NodeType.Category,
|
||||||
}
|
selfNode: category,
|
||||||
if (!category.children || category.children.length === 0) {
|
parentNode: parentCategory,
|
||||||
throw Error(`category has no children: "${category.category}"`);
|
})
|
||||||
}
|
.assertDefined(category)
|
||||||
if (!category.category || category.category.length === 0) {
|
.assertValidName(category.category)
|
||||||
throw Error('category has no name');
|
.assert(
|
||||||
}
|
() => category.children && category.children.length > 0,
|
||||||
|
`"${category.category}" has no children.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ICategoryChildren {
|
interface ICategoryChildren {
|
||||||
@@ -48,22 +82,29 @@ interface ICategoryChildren {
|
|||||||
subScripts: Script[];
|
subScripts: Script[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCategoryChild(
|
interface INodeParseContext {
|
||||||
data: CategoryOrScriptData,
|
readonly nodeData: CategoryOrScriptData;
|
||||||
children: ICategoryChildren,
|
readonly children: ICategoryChildren;
|
||||||
parent: CategoryData,
|
readonly parent: CategoryData;
|
||||||
context: ICategoryCollectionParseContext,
|
readonly factory: CategoryFactoryType;
|
||||||
) {
|
readonly context: ICategoryCollectionParseContext;
|
||||||
if (isCategory(data)) {
|
}
|
||||||
const subCategory = parseCategory(data as CategoryData, context);
|
function parseNode(context: INodeParseContext) {
|
||||||
children.subCategories.push(subCategory);
|
const validator = new NodeValidator({ selfNode: context.nodeData, parentNode: context.parent });
|
||||||
} else if (isScript(data)) {
|
validator.assertDefined(context.nodeData);
|
||||||
const scriptData = data as ScriptData;
|
if (isCategory(context.nodeData)) {
|
||||||
const script = parseScript(scriptData, context);
|
const subCategory = parseCategoryRecursively({
|
||||||
children.subScripts.push(script);
|
categoryData: context.nodeData as CategoryData,
|
||||||
|
context: context.context,
|
||||||
|
factory: context.factory,
|
||||||
|
parentCategory: context.parent,
|
||||||
|
});
|
||||||
|
context.children.subCategories.push(subCategory);
|
||||||
|
} else if (isScript(context.nodeData)) {
|
||||||
|
const script = parseScript(context.nodeData as ScriptData, context.context);
|
||||||
|
context.children.subScripts.push(script);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Child element is neither a category or a script.
|
validator.throw('Node is neither a category or a script.');
|
||||||
Parent: ${parent.category}, element: ${JSON.stringify(data)}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,14 +114,22 @@ function isScript(data: CategoryOrScriptData): data is ScriptData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isCategory(data: CategoryOrScriptData): data is CategoryData {
|
function isCategory(data: CategoryOrScriptData): data is CategoryData {
|
||||||
const { category } = data as CategoryData;
|
return hasProperty(data, 'category');
|
||||||
return category && category.length > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasCode(holder: InstructionHolder): boolean {
|
function hasCode(data: InstructionHolder): boolean {
|
||||||
return holder.code && holder.code.length > 0;
|
return hasProperty(data, 'code');
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasCall(holder: InstructionHolder) {
|
function hasCall(data: InstructionHolder) {
|
||||||
return holder.call !== undefined;
|
return hasProperty(data, 'call');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasProperty(object: unknown, propertyName: string) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(object, propertyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CategoryFactoryType = (
|
||||||
|
...parameters: ConstructorParameters<typeof Category>) => Category;
|
||||||
|
|
||||||
|
const CategoryFactory: CategoryFactoryType = (...parameters) => new Category(...parameters);
|
||||||
|
|||||||
3
src/application/Parser/NodeValidation/NodeData.ts
Normal file
3
src/application/Parser/NodeValidation/NodeData.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { ScriptData, CategoryData } from '@/application/collections/';
|
||||||
|
|
||||||
|
export type NodeData = CategoryData | ScriptData;
|
||||||
35
src/application/Parser/NodeValidation/NodeDataError.ts
Normal file
35
src/application/Parser/NodeValidation/NodeDataError.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NodeType } from './NodeType';
|
||||||
|
import { NodeData } from './NodeData';
|
||||||
|
|
||||||
|
export class NodeDataError extends Error {
|
||||||
|
constructor(message: string, public readonly context: INodeDataErrorContext) {
|
||||||
|
super(createMessage(message, context));
|
||||||
|
Object.setPrototypeOf(this, new.target.prototype); // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget
|
||||||
|
this.name = new.target.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
4
src/application/Parser/NodeValidation/NodeType.ts
Normal file
4
src/application/Parser/NodeValidation/NodeType.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export enum NodeType {
|
||||||
|
Script,
|
||||||
|
Category,
|
||||||
|
}
|
||||||
38
src/application/Parser/NodeValidation/NodeValidator.ts
Normal file
38
src/application/Parser/NodeValidation/NodeValidator.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { INodeDataErrorContext, NodeDataError } from './NodeDataError';
|
||||||
|
import { NodeData } from './NodeData';
|
||||||
|
|
||||||
|
export class NodeValidator {
|
||||||
|
constructor(private readonly context: INodeDataErrorContext) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public assertValidName(nameValue: string) {
|
||||||
|
return this
|
||||||
|
.assert(
|
||||||
|
() => Boolean(nameValue),
|
||||||
|
'missing name',
|
||||||
|
)
|
||||||
|
.assert(
|
||||||
|
() => typeof nameValue === 'string',
|
||||||
|
`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) {
|
||||||
|
throw new NodeDataError(errorMessage, this.context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,22 +5,31 @@ import { IScriptCode } from '@/domain/IScriptCode';
|
|||||||
import { ScriptCode } from '@/domain/ScriptCode';
|
import { ScriptCode } from '@/domain/ScriptCode';
|
||||||
import { parseDocUrls } from '../DocumentationParser';
|
import { parseDocUrls } from '../DocumentationParser';
|
||||||
import { createEnumParser, IEnumParser } from '../../Common/Enum';
|
import { createEnumParser, IEnumParser } from '../../Common/Enum';
|
||||||
|
import { NodeType } from '../NodeValidation/NodeType';
|
||||||
|
import { NodeValidator } from '../NodeValidation/NodeValidator';
|
||||||
import { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext';
|
import { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext';
|
||||||
|
|
||||||
|
// eslint-disable-next-line consistent-return
|
||||||
export function parseScript(
|
export function parseScript(
|
||||||
data: ScriptData,
|
data: ScriptData,
|
||||||
context: ICategoryCollectionParseContext,
|
context: ICategoryCollectionParseContext,
|
||||||
levelParser = createEnumParser(RecommendationLevel),
|
levelParser = createEnumParser(RecommendationLevel),
|
||||||
|
scriptFactory: ScriptFactoryType = ScriptFactory,
|
||||||
): Script {
|
): Script {
|
||||||
validateScript(data);
|
const validator = new NodeValidator({ type: NodeType.Script, selfNode: data });
|
||||||
|
validateScript(data, validator);
|
||||||
if (!context) { throw new Error('missing context'); }
|
if (!context) { throw new Error('missing context'); }
|
||||||
const script = new Script(
|
try {
|
||||||
|
const script = scriptFactory(
|
||||||
/* name: */ data.name,
|
/* name: */ data.name,
|
||||||
/* code: */ parseCode(data, context),
|
/* code: */ parseCode(data, context),
|
||||||
/* docs: */ parseDocUrls(data),
|
/* docs: */ parseDocUrls(data),
|
||||||
/* level: */ parseLevel(data.recommend, levelParser),
|
/* level: */ parseLevel(data.recommend, levelParser),
|
||||||
);
|
);
|
||||||
return script;
|
return script;
|
||||||
|
} catch (err) {
|
||||||
|
validator.throw(err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseLevel(
|
function parseLevel(
|
||||||
@@ -40,21 +49,24 @@ function parseCode(script: ScriptData, context: ICategoryCollectionParseContext)
|
|||||||
return new ScriptCode(script.code, script.revertCode, context.syntax);
|
return new ScriptCode(script.code, script.revertCode, context.syntax);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureNotBothCallAndCode(script: ScriptData) {
|
function validateScript(script: ScriptData, validator: NodeValidator) {
|
||||||
if (script.code && script.call) {
|
validator
|
||||||
throw new Error('cannot define both "call" and "code"');
|
.assertDefined(script)
|
||||||
}
|
.assertValidName(script.name)
|
||||||
if (script.revertCode && script.call) {
|
.assert(
|
||||||
throw new Error('cannot define "revertCode" if "call" is defined');
|
() => Boolean(script.code || script.call),
|
||||||
}
|
'Must define either "call" or "code".',
|
||||||
|
)
|
||||||
|
.assert(
|
||||||
|
() => !(script.code && script.call),
|
||||||
|
'Cannot define both "call" and "code".',
|
||||||
|
)
|
||||||
|
.assert(
|
||||||
|
() => !(script.revertCode && script.call),
|
||||||
|
'Cannot define "revertCode" if "call" is defined.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateScript(script: ScriptData) {
|
export type ScriptFactoryType = (...parameters: ConstructorParameters<typeof Script>) => Script;
|
||||||
if (!script) {
|
|
||||||
throw new Error('missing script');
|
const ScriptFactory: ScriptFactoryType = (...parameters) => new Script(...parameters);
|
||||||
}
|
|
||||||
if (!script.code && !script.call) {
|
|
||||||
throw new Error('must define either "call" or "code"');
|
|
||||||
}
|
|
||||||
ensureNotBothCallAndCode(script);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class Script extends BaseEntity<string> implements IScript {
|
|||||||
) {
|
) {
|
||||||
super(name);
|
super(name);
|
||||||
if (!code) {
|
if (!code) {
|
||||||
throw new Error(`missing code (script: ${name})`);
|
throw new Error('missing code');
|
||||||
}
|
}
|
||||||
validateLevel(level);
|
validateLevel(level);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'mocha';
|
import 'mocha';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
import { parseCategory } from '@/application/Parser/CategoryParser';
|
import type { CategoryData, CategoryOrScriptData } from '@/application/collections/';
|
||||||
|
import { CategoryFactoryType, parseCategory } from '@/application/Parser/CategoryParser';
|
||||||
import { parseScript } from '@/application/Parser/Script/ScriptParser';
|
import { parseScript } from '@/application/Parser/Script/ScriptParser';
|
||||||
import { parseDocUrls } from '@/application/Parser/DocumentationParser';
|
import { parseDocUrls } from '@/application/Parser/DocumentationParser';
|
||||||
import { ScriptCompilerStub } from '@tests/unit/shared/Stubs/ScriptCompilerStub';
|
import { ScriptCompilerStub } from '@tests/unit/shared/Stubs/ScriptCompilerStub';
|
||||||
@@ -8,60 +9,147 @@ import { ScriptDataStub } from '@tests/unit/shared/Stubs/ScriptDataStub';
|
|||||||
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 { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
|
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
|
||||||
import { itEachAbsentCollectionValue, itEachAbsentObjectValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
import { itEachAbsentCollectionValue, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||||
|
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
|
||||||
|
import { expectThrowsNodeError, ITestScenario, NodeValidationTestRunner } from '@tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner';
|
||||||
|
import { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext';
|
||||||
|
import { Category } from '@/domain/Category';
|
||||||
|
|
||||||
describe('CategoryParser', () => {
|
describe('CategoryParser', () => {
|
||||||
describe('parseCategory', () => {
|
describe('parseCategory', () => {
|
||||||
describe('invalid category', () => {
|
describe('invalid category data', () => {
|
||||||
describe('throws when category data is absent', () => {
|
describe('validates script data', () => {
|
||||||
itEachAbsentObjectValue((absentValue) => {
|
describe('satisfies shared node tests', () => {
|
||||||
// arrange
|
new NodeValidationTestRunner()
|
||||||
const expectedMessage = 'missing category';
|
.testInvalidNodeName((invalidName) => {
|
||||||
const category = absentValue;
|
return createTest(
|
||||||
const context = new CategoryCollectionParseContextStub();
|
new CategoryDataStub().withName(invalidName),
|
||||||
// act
|
);
|
||||||
const act = () => parseCategory(category, context);
|
})
|
||||||
// assert
|
.testMissingNodeData((node) => {
|
||||||
expect(act).to.throw(expectedMessage);
|
return createTest(node as CategoryData);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('throws when category children is absent', () => {
|
describe('throws when category children is absent', () => {
|
||||||
itEachAbsentCollectionValue((absentValue) => {
|
itEachAbsentCollectionValue((absentValue) => {
|
||||||
// arrange
|
// arrange
|
||||||
const categoryName = 'test';
|
const categoryName = 'test';
|
||||||
const expectedMessage = `category has no children: "${categoryName}"`;
|
const expectedMessage = `"${categoryName}" has no children.`;
|
||||||
const category = new CategoryDataStub()
|
const category = new CategoryDataStub()
|
||||||
.withName(categoryName)
|
.withName(categoryName)
|
||||||
.withChildren(absentValue);
|
.withChildren(absentValue);
|
||||||
const context = new CategoryCollectionParseContextStub();
|
|
||||||
// act
|
// act
|
||||||
const act = () => parseCategory(category, context);
|
const test = createTest(category);
|
||||||
// assert
|
// assert
|
||||||
expect(act).to.throw(expectedMessage);
|
expectThrowsNodeError(test, expectedMessage);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('throws when name is absent', () => {
|
describe('throws when category child is missing', () => {
|
||||||
itEachAbsentStringValue((absentValue) => {
|
new NodeValidationTestRunner()
|
||||||
|
.testMissingNodeData((missingNode) => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedMessage = 'category has no name';
|
const invalidChildNode = missingNode;
|
||||||
const category = new CategoryDataStub()
|
const parent = new CategoryDataStub()
|
||||||
.withName(absentValue);
|
.withName('parent')
|
||||||
const context = new CategoryCollectionParseContextStub();
|
.withChildren([new CategoryDataStub().withName('valid child'), invalidChildNode]);
|
||||||
|
return ({
|
||||||
// act
|
// act
|
||||||
const act = () => parseCategory(category, context);
|
act: () => new TestBuilder()
|
||||||
|
.withData(parent)
|
||||||
|
.parseCategory(),
|
||||||
// assert
|
// assert
|
||||||
expect(act).to.throw(expectedMessage);
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it(`rethrows exception if ${Category.name} cannot be constructed`, () => {
|
||||||
|
// arrange
|
||||||
|
const expectedError = 'category creation failed';
|
||||||
|
const factoryMock: CategoryFactoryType = () => { throw new Error(expectedError); };
|
||||||
|
const data = new CategoryDataStub();
|
||||||
|
// act
|
||||||
|
const act = () => new TestBuilder()
|
||||||
|
.withData(data)
|
||||||
|
.withFactory(factoryMock)
|
||||||
|
.parseCategory();
|
||||||
|
// expect
|
||||||
|
expectThrowsNodeError({
|
||||||
|
act,
|
||||||
|
expectedContext: {
|
||||||
|
type: NodeType.Category,
|
||||||
|
selfNode: data,
|
||||||
|
},
|
||||||
|
}, expectedError);
|
||||||
|
});
|
||||||
|
});
|
||||||
describe('throws when context is absent', () => {
|
describe('throws when context is absent', () => {
|
||||||
itEachAbsentObjectValue((absentValue) => {
|
itEachAbsentObjectValue((absentValue) => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedError = 'missing context';
|
const expectedError = 'missing context';
|
||||||
const context = absentValue;
|
const context = absentValue;
|
||||||
const category = new CategoryDataStub();
|
|
||||||
// act
|
// act
|
||||||
const act = () => parseCategory(category, context);
|
const act = () => new TestBuilder()
|
||||||
|
.withContext(context)
|
||||||
|
.parseCategory();
|
||||||
// assert
|
// assert
|
||||||
expect(act).to.throw(expectedError);
|
expect(act).to.throw(expectedError);
|
||||||
});
|
});
|
||||||
@@ -72,9 +160,11 @@ describe('CategoryParser', () => {
|
|||||||
const expected = parseDocUrls({ docs: url });
|
const expected = parseDocUrls({ docs: url });
|
||||||
const category = new CategoryDataStub()
|
const category = new CategoryDataStub()
|
||||||
.withDocs(url);
|
.withDocs(url);
|
||||||
const context = new CategoryCollectionParseContextStub();
|
|
||||||
// act
|
// act
|
||||||
const actual = parseCategory(category, context).documentationUrls;
|
const actual = new TestBuilder()
|
||||||
|
.withData(category)
|
||||||
|
.parseCategory()
|
||||||
|
.documentationUrls;
|
||||||
// assert
|
// assert
|
||||||
expect(actual).to.deep.equal(expected);
|
expect(actual).to.deep.equal(expected);
|
||||||
});
|
});
|
||||||
@@ -87,7 +177,11 @@ describe('CategoryParser', () => {
|
|||||||
const category = new CategoryDataStub()
|
const category = new CategoryDataStub()
|
||||||
.withChildren([script]);
|
.withChildren([script]);
|
||||||
// act
|
// act
|
||||||
const actual = parseCategory(category, context).scripts;
|
const actual = new TestBuilder()
|
||||||
|
.withData(category)
|
||||||
|
.withContext(context)
|
||||||
|
.parseCategory()
|
||||||
|
.scripts;
|
||||||
// assert
|
// assert
|
||||||
expect(actual).to.deep.equal(expected);
|
expect(actual).to.deep.equal(expected);
|
||||||
});
|
});
|
||||||
@@ -102,7 +196,11 @@ describe('CategoryParser', () => {
|
|||||||
const category = new CategoryDataStub()
|
const category = new CategoryDataStub()
|
||||||
.withChildren([script]);
|
.withChildren([script]);
|
||||||
// act
|
// act
|
||||||
const actual = parseCategory(category, context).scripts;
|
const actual = new TestBuilder()
|
||||||
|
.withData(category)
|
||||||
|
.withContext(context)
|
||||||
|
.parseCategory()
|
||||||
|
.scripts;
|
||||||
// assert
|
// assert
|
||||||
expect(actual).to.deep.equal(expected);
|
expect(actual).to.deep.equal(expected);
|
||||||
});
|
});
|
||||||
@@ -118,7 +216,11 @@ describe('CategoryParser', () => {
|
|||||||
.withCompiler(compiler);
|
.withCompiler(compiler);
|
||||||
const expected = scripts.map((script) => parseScript(script, context));
|
const expected = scripts.map((script) => parseScript(script, context));
|
||||||
// act
|
// act
|
||||||
const actual = parseCategory(category, context).scripts;
|
const actual = new TestBuilder()
|
||||||
|
.withData(category)
|
||||||
|
.withContext(context)
|
||||||
|
.parseCategory()
|
||||||
|
.scripts;
|
||||||
// assert
|
// assert
|
||||||
expect(actual).to.deep.equal(expected);
|
expect(actual).to.deep.equal(expected);
|
||||||
});
|
});
|
||||||
@@ -139,7 +241,11 @@ describe('CategoryParser', () => {
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
// act
|
// act
|
||||||
const act = () => parseCategory(category, parseContext).scripts;
|
const act = () => new TestBuilder()
|
||||||
|
.withData(category)
|
||||||
|
.withContext(parseContext)
|
||||||
|
.parseCategory()
|
||||||
|
.scripts;
|
||||||
// assert
|
// assert
|
||||||
expect(act).to.not.throw();
|
expect(act).to.not.throw();
|
||||||
});
|
});
|
||||||
@@ -153,9 +259,11 @@ describe('CategoryParser', () => {
|
|||||||
const category = new CategoryDataStub()
|
const category = new CategoryDataStub()
|
||||||
.withName('category name')
|
.withName('category name')
|
||||||
.withChildren(expected);
|
.withChildren(expected);
|
||||||
const context = new CategoryCollectionParseContextStub();
|
|
||||||
// act
|
// act
|
||||||
const actual = parseCategory(category, context).subCategories;
|
const actual = new TestBuilder()
|
||||||
|
.withData(category)
|
||||||
|
.parseCategory()
|
||||||
|
.subCategories;
|
||||||
// assert
|
// assert
|
||||||
expect(actual).to.have.lengthOf(1);
|
expect(actual).to.have.lengthOf(1);
|
||||||
expect(actual[0].name).to.equal(expected[0].category);
|
expect(actual[0].name).to.equal(expected[0].category);
|
||||||
@@ -163,3 +271,30 @@ describe('CategoryParser', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
class TestBuilder {
|
||||||
|
private data: CategoryData = new CategoryDataStub();
|
||||||
|
|
||||||
|
private context: ICategoryCollectionParseContext = new CategoryCollectionParseContextStub();
|
||||||
|
|
||||||
|
private factory: CategoryFactoryType = undefined;
|
||||||
|
|
||||||
|
public withData(data: CategoryData) {
|
||||||
|
this.data = data;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withContext(context: ICategoryCollectionParseContext) {
|
||||||
|
this.context = context;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withFactory(factory: CategoryFactoryType) {
|
||||||
|
this.factory = factory;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public parseCategory() {
|
||||||
|
return parseCategory(this.data, this.context, this.factory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'mocha';
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import { INodeDataErrorContext, NodeDataError } from '@/application/Parser/NodeValidation/NodeDataError';
|
||||||
|
import { NodeDataErrorContextStub } from '@tests/unit/shared/Stubs/NodeDataErrorContextStub';
|
||||||
|
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
|
||||||
|
|
||||||
|
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('sets stack as expected', () => {
|
||||||
|
// arrange
|
||||||
|
// act
|
||||||
|
const sut = new NodeDataErrorBuilder()
|
||||||
|
.build();
|
||||||
|
// assert
|
||||||
|
expect(sut.stack !== undefined);
|
||||||
|
});
|
||||||
|
it('extends Error', () => {
|
||||||
|
// arrange
|
||||||
|
const expected = Error;
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import 'mocha';
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import { NodeDataError } from '@/application/Parser/NodeValidation/NodeDataError';
|
||||||
|
import { NodeValidator } from '@/application/Parser/NodeValidation/NodeValidator';
|
||||||
|
import { expectThrowsError } from '@tests/unit/shared/Assertions/ExpectThrowsError';
|
||||||
|
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
|
||||||
|
import { NodeDataErrorContextStub } from '@tests/unit/shared/Stubs/NodeDataErrorContextStub';
|
||||||
|
import { 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
|
||||||
|
expectThrowsError(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
|
||||||
|
expectThrowsError(act, expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockNode() {
|
||||||
|
return new CategoryDataStub();
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'mocha';
|
||||||
|
import { NodeDataError, INodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataError';
|
||||||
|
import { NodeData } from '@/application/Parser/NodeValidation/NodeData';
|
||||||
|
import { AbsentObjectTestCases, AbsentStringTestCases, itEachAbsentTestCase } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||||
|
import { expectThrowsError } from '@tests/unit/shared/Assertions/ExpectThrowsError';
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
...AbsentStringTestCases.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([
|
||||||
|
...AbsentObjectTestCases,
|
||||||
|
{
|
||||||
|
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
|
||||||
|
expectThrowsError(act, expected);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
import 'mocha';
|
import 'mocha';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
import { parseScript } from '@/application/Parser/Script/ScriptParser';
|
import type { ScriptData } from '@/application/collections/';
|
||||||
|
import { parseScript, ScriptFactoryType } from '@/application/Parser/Script/ScriptParser';
|
||||||
import { parseDocUrls } from '@/application/Parser/DocumentationParser';
|
import { parseDocUrls } from '@/application/Parser/DocumentationParser';
|
||||||
import { RecommendationLevel } from '@/domain/RecommendationLevel';
|
import { RecommendationLevel } from '@/domain/RecommendationLevel';
|
||||||
import { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext';
|
import { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext';
|
||||||
|
import { itEachAbsentObjectValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||||
import { ScriptCompilerStub } from '@tests/unit/shared/Stubs/ScriptCompilerStub';
|
import { ScriptCompilerStub } from '@tests/unit/shared/Stubs/ScriptCompilerStub';
|
||||||
import { ScriptDataStub } from '@tests/unit/shared/Stubs/ScriptDataStub';
|
import { ScriptDataStub } from '@tests/unit/shared/Stubs/ScriptDataStub';
|
||||||
import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub';
|
import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub';
|
||||||
import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
|
import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
|
||||||
import { 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 { itEachAbsentObjectValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
|
||||||
|
import { expectThrowsNodeError, ITestScenario, NodeValidationTestRunner } from '@tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner';
|
||||||
|
import { Script } from '@/domain/Script';
|
||||||
|
import { IEnumParser } from '@/application/Common/Enum';
|
||||||
|
|
||||||
describe('ScriptParser', () => {
|
describe('ScriptParser', () => {
|
||||||
describe('parseScript', () => {
|
describe('parseScript', () => {
|
||||||
@@ -19,9 +24,10 @@ describe('ScriptParser', () => {
|
|||||||
const expected = 'test-expected-name';
|
const expected = 'test-expected-name';
|
||||||
const script = ScriptDataStub.createWithCode()
|
const script = ScriptDataStub.createWithCode()
|
||||||
.withName(expected);
|
.withName(expected);
|
||||||
const parseContext = new CategoryCollectionParseContextStub();
|
|
||||||
// act
|
// act
|
||||||
const actual = parseScript(script, parseContext);
|
const actual = new TestBuilder()
|
||||||
|
.withData(script)
|
||||||
|
.parseScript();
|
||||||
// assert
|
// assert
|
||||||
expect(actual.name).to.equal(expected);
|
expect(actual.name).to.equal(expected);
|
||||||
});
|
});
|
||||||
@@ -30,86 +36,42 @@ describe('ScriptParser', () => {
|
|||||||
const docs = ['https://expected-doc1.com', 'https://expected-doc2.com'];
|
const docs = ['https://expected-doc1.com', 'https://expected-doc2.com'];
|
||||||
const script = ScriptDataStub.createWithCode()
|
const script = ScriptDataStub.createWithCode()
|
||||||
.withDocs(docs);
|
.withDocs(docs);
|
||||||
const parseContext = new CategoryCollectionParseContextStub();
|
|
||||||
const expected = parseDocUrls(script);
|
const expected = parseDocUrls(script);
|
||||||
// act
|
// act
|
||||||
const actual = parseScript(script, parseContext);
|
const actual = new TestBuilder()
|
||||||
|
.withData(script)
|
||||||
|
.parseScript();
|
||||||
// assert
|
// assert
|
||||||
expect(actual.documentationUrls).to.deep.equal(expected);
|
expect(actual.documentationUrls).to.deep.equal(expected);
|
||||||
});
|
});
|
||||||
describe('invalid script', () => {
|
|
||||||
describe('throws when script is absent', () => {
|
|
||||||
itEachAbsentObjectValue((absentValue) => {
|
|
||||||
// arrange
|
|
||||||
const expectedError = 'missing script';
|
|
||||||
const parseContext = new CategoryCollectionParseContextStub();
|
|
||||||
const script = absentValue;
|
|
||||||
// act
|
|
||||||
const act = () => parseScript(script, parseContext);
|
|
||||||
// assert
|
|
||||||
expect(act).to.throw(expectedError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('throws when both function call and code are defined', () => {
|
|
||||||
// arrange
|
|
||||||
const expectedError = 'cannot define both "call" and "code"';
|
|
||||||
const parseContext = new CategoryCollectionParseContextStub();
|
|
||||||
const script = ScriptDataStub
|
|
||||||
.createWithCall()
|
|
||||||
.withCode('code');
|
|
||||||
// act
|
|
||||||
const act = () => parseScript(script, parseContext);
|
|
||||||
// assert
|
|
||||||
expect(act).to.throw(expectedError);
|
|
||||||
});
|
|
||||||
it('throws when both function call and revertCode are defined', () => {
|
|
||||||
// arrange
|
|
||||||
const expectedError = 'cannot define "revertCode" if "call" is defined';
|
|
||||||
const parseContext = new CategoryCollectionParseContextStub();
|
|
||||||
const script = ScriptDataStub
|
|
||||||
.createWithCall()
|
|
||||||
.withRevertCode('revert-code');
|
|
||||||
// act
|
|
||||||
const act = () => parseScript(script, parseContext);
|
|
||||||
// assert
|
|
||||||
expect(act).to.throw(expectedError);
|
|
||||||
});
|
|
||||||
it('throws when neither call or revertCode are defined', () => {
|
|
||||||
// arrange
|
|
||||||
const expectedError = 'must define either "call" or "code"';
|
|
||||||
const parseContext = new CategoryCollectionParseContextStub();
|
|
||||||
const script = ScriptDataStub.createWithoutCallOrCodes();
|
|
||||||
// act
|
|
||||||
const act = () => parseScript(script, parseContext);
|
|
||||||
// assert
|
|
||||||
expect(act).to.throw(expectedError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe('level', () => {
|
describe('level', () => {
|
||||||
describe('accepts absent level', () => {
|
describe('accepts absent level', () => {
|
||||||
itEachAbsentStringValue((absentValue) => {
|
itEachAbsentStringValue((absentValue) => {
|
||||||
// arrange
|
// arrange
|
||||||
const parseContext = new CategoryCollectionParseContextStub();
|
|
||||||
const script = ScriptDataStub.createWithCode()
|
const script = ScriptDataStub.createWithCode()
|
||||||
.withRecommend(absentValue);
|
.withRecommend(absentValue);
|
||||||
// act
|
// act
|
||||||
const actual = parseScript(script, parseContext);
|
const actual = new TestBuilder()
|
||||||
|
.withData(script)
|
||||||
|
.parseScript();
|
||||||
// assert
|
// assert
|
||||||
expect(actual.level).to.equal(undefined);
|
expect(actual.level).to.equal(undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('parses level as expected', () => {
|
it('parses level as expected', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedLevel = RecommendationLevel.Standard;
|
const expectedLevel = RecommendationLevel.Standard;
|
||||||
const expectedName = 'level';
|
const expectedName = 'level';
|
||||||
const levelText = 'standard';
|
const levelText = 'standard';
|
||||||
const script = ScriptDataStub.createWithCode()
|
const script = ScriptDataStub.createWithCode()
|
||||||
.withRecommend(levelText);
|
.withRecommend(levelText);
|
||||||
const parseContext = new CategoryCollectionParseContextStub();
|
|
||||||
const parserMock = new EnumParserStub<RecommendationLevel>()
|
const parserMock = new EnumParserStub<RecommendationLevel>()
|
||||||
.setup(expectedName, levelText, expectedLevel);
|
.setup(expectedName, levelText, expectedLevel);
|
||||||
// act
|
// act
|
||||||
const actual = parseScript(script, parseContext, parserMock);
|
const actual = new TestBuilder()
|
||||||
|
.withData(script)
|
||||||
|
.withParser(parserMock)
|
||||||
|
.parseScript();
|
||||||
// assert
|
// assert
|
||||||
expect(actual.level).to.equal(expectedLevel);
|
expect(actual.level).to.equal(expectedLevel);
|
||||||
});
|
});
|
||||||
@@ -121,9 +83,10 @@ describe('ScriptParser', () => {
|
|||||||
const script = ScriptDataStub
|
const script = ScriptDataStub
|
||||||
.createWithCode()
|
.createWithCode()
|
||||||
.withCode(expected);
|
.withCode(expected);
|
||||||
const parseContext = new CategoryCollectionParseContextStub();
|
|
||||||
// act
|
// act
|
||||||
const parsed = parseScript(script, parseContext);
|
const parsed = new TestBuilder()
|
||||||
|
.withData(script)
|
||||||
|
.parseScript();
|
||||||
// assert
|
// assert
|
||||||
const actual = parsed.code.execute;
|
const actual = parsed.code.execute;
|
||||||
expect(actual).to.equal(expected);
|
expect(actual).to.equal(expected);
|
||||||
@@ -134,9 +97,10 @@ describe('ScriptParser', () => {
|
|||||||
const script = ScriptDataStub
|
const script = ScriptDataStub
|
||||||
.createWithCode()
|
.createWithCode()
|
||||||
.withRevertCode(expected);
|
.withRevertCode(expected);
|
||||||
const parseContext = new CategoryCollectionParseContextStub();
|
|
||||||
// act
|
// act
|
||||||
const parsed = parseScript(script, parseContext);
|
const parsed = new TestBuilder()
|
||||||
|
.withData(script)
|
||||||
|
.parseScript();
|
||||||
// assert
|
// assert
|
||||||
const actual = parsed.code.revert;
|
const actual = parsed.code.revert;
|
||||||
expect(actual).to.equal(expected);
|
expect(actual).to.equal(expected);
|
||||||
@@ -146,10 +110,11 @@ describe('ScriptParser', () => {
|
|||||||
itEachAbsentObjectValue((absentValue) => {
|
itEachAbsentObjectValue((absentValue) => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedMessage = 'missing context';
|
const expectedMessage = 'missing context';
|
||||||
const script = ScriptDataStub.createWithCode();
|
|
||||||
const context: ICategoryCollectionParseContext = absentValue;
|
const context: ICategoryCollectionParseContext = absentValue;
|
||||||
// act
|
// act
|
||||||
const act = () => parseScript(script, context);
|
const act = () => new TestBuilder()
|
||||||
|
.withContext(context)
|
||||||
|
.parseScript();
|
||||||
// assert
|
// assert
|
||||||
expect(act).to.throw(expectedMessage);
|
expect(act).to.throw(expectedMessage);
|
||||||
});
|
});
|
||||||
@@ -163,14 +128,17 @@ describe('ScriptParser', () => {
|
|||||||
const parseContext = new CategoryCollectionParseContextStub()
|
const parseContext = new CategoryCollectionParseContextStub()
|
||||||
.withCompiler(compiler);
|
.withCompiler(compiler);
|
||||||
// act
|
// act
|
||||||
const parsed = parseScript(script, parseContext);
|
const parsed = new TestBuilder()
|
||||||
|
.withData(script)
|
||||||
|
.withContext(parseContext)
|
||||||
|
.parseScript();
|
||||||
// assert
|
// assert
|
||||||
const actual = parsed.code;
|
const actual = parsed.code;
|
||||||
expect(actual).to.equal(expected);
|
expect(actual).to.equal(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('syntax', () => {
|
describe('syntax', () => {
|
||||||
it('set from the context', () => { // test through script validation logic
|
it('set from the context', () => { // tests through script validation logic
|
||||||
// arrange
|
// arrange
|
||||||
const commentDelimiter = 'should not throw';
|
const commentDelimiter = 'should not throw';
|
||||||
const duplicatedCode = `${commentDelimiter} duplicate-line\n${commentDelimiter} duplicate-line`;
|
const duplicatedCode = `${commentDelimiter} duplicate-line\n${commentDelimiter} duplicate-line`;
|
||||||
@@ -180,11 +148,112 @@ describe('ScriptParser', () => {
|
|||||||
.createWithoutCallOrCodes()
|
.createWithoutCallOrCodes()
|
||||||
.withCode(duplicatedCode);
|
.withCode(duplicatedCode);
|
||||||
// act
|
// act
|
||||||
const act = () => parseScript(script, parseContext);
|
const act = () => new TestBuilder()
|
||||||
|
.withData(script)
|
||||||
|
.withContext(parseContext);
|
||||||
// assert
|
// assert
|
||||||
expect(act).to.not.throw();
|
expect(act).to.not.throw();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('invalid script data', () => {
|
||||||
|
describe('validates script data', () => {
|
||||||
|
// arrange
|
||||||
|
const createTest = (script: ScriptData): ITestScenario => ({
|
||||||
|
act: () => new TestBuilder()
|
||||||
|
.withData(script)
|
||||||
|
.parseScript(),
|
||||||
|
expectedContext: {
|
||||||
|
type: NodeType.Script,
|
||||||
|
selfNode: script,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// act and assert
|
||||||
|
new NodeValidationTestRunner()
|
||||||
|
.testInvalidNodeName((invalidName) => {
|
||||||
|
return createTest(
|
||||||
|
ScriptDataStub.createWithCall().withName(invalidName),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.testMissingNodeData((node) => {
|
||||||
|
return createTest(node as ScriptData);
|
||||||
|
})
|
||||||
|
.runThrowingCase({
|
||||||
|
name: 'throws when both function call and code are defined',
|
||||||
|
scenario: createTest(
|
||||||
|
ScriptDataStub.createWithCall().withCode('code'),
|
||||||
|
),
|
||||||
|
expectedMessage: 'Cannot define both "call" and "code".',
|
||||||
|
})
|
||||||
|
.runThrowingCase({
|
||||||
|
name: 'throws when both function call and revertCode are defined',
|
||||||
|
scenario: createTest(
|
||||||
|
ScriptDataStub.createWithCall().withRevertCode('revert-code'),
|
||||||
|
),
|
||||||
|
expectedMessage: 'Cannot define "revertCode" if "call" is defined.',
|
||||||
|
})
|
||||||
|
.runThrowingCase({
|
||||||
|
name: 'throws when neither call or revertCode are defined',
|
||||||
|
scenario: createTest(
|
||||||
|
ScriptDataStub.createWithoutCallOrCodes(),
|
||||||
|
),
|
||||||
|
expectedMessage: 'Must define either "call" or "code".',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it(`rethrows exception if ${Script.name} cannot be constructed`, () => {
|
||||||
|
// arrange
|
||||||
|
const expectedError = 'script creation failed';
|
||||||
|
const factoryMock: ScriptFactoryType = () => { throw new Error(expectedError); };
|
||||||
|
const data = ScriptDataStub.createWithCode();
|
||||||
|
// act
|
||||||
|
const act = () => new TestBuilder()
|
||||||
|
.withData(data)
|
||||||
|
.withFactory(factoryMock)
|
||||||
|
.parseScript();
|
||||||
|
// expect
|
||||||
|
expectThrowsNodeError({
|
||||||
|
act,
|
||||||
|
expectedContext: {
|
||||||
|
type: NodeType.Script,
|
||||||
|
selfNode: data,
|
||||||
|
},
|
||||||
|
}, expectedError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class TestBuilder {
|
||||||
|
private data: ScriptData = ScriptDataStub.createWithCode();
|
||||||
|
|
||||||
|
private context: ICategoryCollectionParseContext = new CategoryCollectionParseContextStub();
|
||||||
|
|
||||||
|
private parser: IEnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>()
|
||||||
|
.setupDefaultValue(RecommendationLevel.Standard);
|
||||||
|
|
||||||
|
private factory: ScriptFactoryType = undefined;
|
||||||
|
|
||||||
|
public withData(data: ScriptData) {
|
||||||
|
this.data = data;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withContext(context: ICategoryCollectionParseContext) {
|
||||||
|
this.context = context;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withParser(parser: IEnumParser<RecommendationLevel>) {
|
||||||
|
this.parser = parser;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withFactory(factory: ScriptFactoryType) {
|
||||||
|
this.factory = factory;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public parseScript(): Script {
|
||||||
|
return parseScript(this.data, this.context, this.parser, this.factory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,12 +24,10 @@ describe('Script', () => {
|
|||||||
describe('throws when missing', () => {
|
describe('throws when missing', () => {
|
||||||
itEachAbsentObjectValue((absentValue) => {
|
itEachAbsentObjectValue((absentValue) => {
|
||||||
// arrange
|
// arrange
|
||||||
const name = 'script-name';
|
const expectedError = 'missing code';
|
||||||
const expectedError = `missing code (script: ${name})`;
|
|
||||||
const code: IScriptCode = absentValue;
|
const code: IScriptCode = absentValue;
|
||||||
// act
|
// act
|
||||||
const construct = () => new ScriptBuilder()
|
const construct = () => new ScriptBuilder()
|
||||||
.withName(name)
|
|
||||||
.withCode(code)
|
.withCode(code)
|
||||||
.build();
|
.build();
|
||||||
// assert
|
// assert
|
||||||
|
|||||||
50
tests/unit/shared/Assertions/ExpectThrowsError.ts
Normal file
50
tests/unit/shared/Assertions/ExpectThrowsError.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// import * as assert from 'assert';
|
||||||
|
import { expect } from 'chai';
|
||||||
|
|
||||||
|
// Using manual checks because expect(act).to.throw(obj) does not work
|
||||||
|
// chaijs/chai#1065, chaijs/chai#1405
|
||||||
|
export function expectThrowsError<T extends Error>(delegate: () => void, expected: T) {
|
||||||
|
// arrange
|
||||||
|
if (!expected) {
|
||||||
|
throw new Error('missing expected');
|
||||||
|
}
|
||||||
|
let actual: T | undefined;
|
||||||
|
// act
|
||||||
|
try {
|
||||||
|
delegate();
|
||||||
|
} catch (error) {
|
||||||
|
actual = error;
|
||||||
|
}
|
||||||
|
// assert
|
||||||
|
expect(Boolean(actual)).to.equal(true, `Expected to throw "${expected.name}" but delegate did not throw at all.`);
|
||||||
|
expect(Boolean(actual?.stack)).to.equal(true, 'Empty stack trace.');
|
||||||
|
expect(expected.message).to.equal(actual.message);
|
||||||
|
expect(expected.name).to.equal(actual.name);
|
||||||
|
expectDeepEqualsIgnoringUndefined(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectDeepEqualsIgnoringUndefined(expected: unknown, actual: unknown) {
|
||||||
|
const actualClean = removeUndefinedProperties(actual);
|
||||||
|
const expectedClean = removeUndefinedProperties(expected);
|
||||||
|
expect(expectedClean).to.deep.equal(actualClean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUndefinedProperties(obj: unknown): unknown {
|
||||||
|
return Object.keys(obj ?? {})
|
||||||
|
.reduce((acc, key) => {
|
||||||
|
const value = obj[key];
|
||||||
|
switch (typeof value) {
|
||||||
|
case 'object': {
|
||||||
|
const cleanValue = removeUndefinedProperties(value); // recurse
|
||||||
|
if (!Object.keys(cleanValue).length) {
|
||||||
|
return { ...acc };
|
||||||
|
}
|
||||||
|
return { ...acc, [key]: cleanValue };
|
||||||
|
}
|
||||||
|
case 'undefined':
|
||||||
|
return { ...acc };
|
||||||
|
default:
|
||||||
|
return { ...acc, [key]: value };
|
||||||
|
}
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
@@ -23,9 +23,9 @@ export class EnumParserStub<T> implements IEnumParser<T> {
|
|||||||
if (scenario) {
|
if (scenario) {
|
||||||
return scenario.outputValue;
|
return scenario.outputValue;
|
||||||
}
|
}
|
||||||
if (this.defaultValue) {
|
if (this.defaultValue !== undefined) {
|
||||||
return this.defaultValue;
|
return this.defaultValue;
|
||||||
}
|
}
|
||||||
throw new Error('enum parser is not set up');
|
throw new Error(`Don't know now what to return from ${EnumParserStub.name}, forgot to set-up?`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
tests/unit/shared/Stubs/NodeDataErrorContextStub.ts
Normal file
12
tests/unit/shared/Stubs/NodeDataErrorContextStub.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { NodeData } from '@/application/Parser/NodeValidation/NodeData';
|
||||||
|
import { INodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataError';
|
||||||
|
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
|
||||||
|
import { CategoryDataStub } from './CategoryDataStub';
|
||||||
|
|
||||||
|
export class NodeDataErrorContextStub implements INodeDataErrorContext {
|
||||||
|
public readonly type: NodeType = NodeType.Script;
|
||||||
|
|
||||||
|
public readonly selfNode: NodeData = new CategoryDataStub();
|
||||||
|
|
||||||
|
public readonly parentNode?: NodeData;
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
export function itEachAbsentStringValue(runner: (absentValue: string) => void): void {
|
export function itEachAbsentStringValue(runner: (absentValue: string) => void): void {
|
||||||
itEachTestCase(AbsentStringTestCases, runner);
|
itEachAbsentTestCase(AbsentStringTestCases, runner);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function itEachAbsentObjectValue(runner: (absentValue: AbsentObjectType) => void): void {
|
export function itEachAbsentObjectValue(
|
||||||
itEachTestCase(AbsentObjectTestCases, runner);
|
runner: (absentValue: AbsentObjectType) => void,
|
||||||
|
): void {
|
||||||
|
itEachAbsentTestCase(AbsentObjectTestCases, runner);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function itEachAbsentCollectionValue<T>(runner: (absentValue: []) => void): void {
|
export function itEachAbsentCollectionValue<T>(runner: (absentValue: []) => void): void {
|
||||||
itEachTestCase(getAbsentCollectionTestCases<T>(), runner);
|
itEachAbsentTestCase(getAbsentCollectionTestCases<T>(), runner);
|
||||||
}
|
}
|
||||||
|
|
||||||
function itEachTestCase<T>(
|
export function itEachAbsentTestCase<T>(
|
||||||
testCases: readonly IAbsentTestCase<T>[],
|
testCases: readonly IAbsentTestCase<T>[],
|
||||||
runner: (absentValue: T) => void,
|
runner: (absentValue: T) => void,
|
||||||
): void {
|
): void {
|
||||||
@@ -53,8 +55,8 @@ export function getAbsentCollectionTestCases<T>(): readonly IAbsentCollectionCas
|
|||||||
type AbsentObjectType = undefined | null;
|
type AbsentObjectType = undefined | null;
|
||||||
|
|
||||||
interface IAbsentTestCase<T> {
|
interface IAbsentTestCase<T> {
|
||||||
valueName: string;
|
readonly valueName: string;
|
||||||
absentValue: T;
|
readonly absentValue: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
|||||||
Reference in New Issue
Block a user