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:
undergroundwires
2021-12-16 02:40:56 +01:00
parent 65902e5b72
commit b210aaddf2
17 changed files with 857 additions and 196 deletions

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
import type { ScriptData, CategoryData } from '@/application/collections/';
export type NodeData = CategoryData | ScriptData;

View 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;
}

View File

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

View 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);
}
}

View File

@@ -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);
}

View File

@@ -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);
} }

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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

View 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 };
}
}, {});
}

View File

@@ -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?`);
} }
} }

View 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;
}

View File

@@ -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