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:
@@ -1,6 +1,7 @@
|
||||
import 'mocha';
|
||||
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 { parseDocUrls } from '@/application/Parser/DocumentationParser';
|
||||
import { ScriptCompilerStub } from '@tests/unit/shared/Stubs/ScriptCompilerStub';
|
||||
@@ -8,50 +9,136 @@ import { ScriptDataStub } from '@tests/unit/shared/Stubs/ScriptDataStub';
|
||||
import { CategoryCollectionParseContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionParseContextStub';
|
||||
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
|
||||
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
|
||||
import { 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('parseCategory', () => {
|
||||
describe('invalid category', () => {
|
||||
describe('throws when category data is absent', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedMessage = 'missing category';
|
||||
const category = absentValue;
|
||||
const context = new CategoryCollectionParseContextStub();
|
||||
// act
|
||||
const act = () => parseCategory(category, context);
|
||||
// assert
|
||||
expect(act).to.throw(expectedMessage);
|
||||
describe('invalid category data', () => {
|
||||
describe('validates script data', () => {
|
||||
describe('satisfies shared node tests', () => {
|
||||
new NodeValidationTestRunner()
|
||||
.testInvalidNodeName((invalidName) => {
|
||||
return createTest(
|
||||
new CategoryDataStub().withName(invalidName),
|
||||
);
|
||||
})
|
||||
.testMissingNodeData((node) => {
|
||||
return createTest(node as CategoryData);
|
||||
});
|
||||
});
|
||||
describe('throws when category children is absent', () => {
|
||||
itEachAbsentCollectionValue((absentValue) => {
|
||||
// arrange
|
||||
const categoryName = 'test';
|
||||
const expectedMessage = `"${categoryName}" has no children.`;
|
||||
const category = new CategoryDataStub()
|
||||
.withName(categoryName)
|
||||
.withChildren(absentValue);
|
||||
// act
|
||||
const test = createTest(category);
|
||||
// assert
|
||||
expectThrowsNodeError(test, expectedMessage);
|
||||
});
|
||||
});
|
||||
describe('throws when category child is missing', () => {
|
||||
new NodeValidationTestRunner()
|
||||
.testMissingNodeData((missingNode) => {
|
||||
// arrange
|
||||
const invalidChildNode = missingNode;
|
||||
const parent = new CategoryDataStub()
|
||||
.withName('parent')
|
||||
.withChildren([new CategoryDataStub().withName('valid child'), invalidChildNode]);
|
||||
return ({
|
||||
// act
|
||||
act: () => new TestBuilder()
|
||||
.withData(parent)
|
||||
.parseCategory(),
|
||||
// assert
|
||||
expectedContext: {
|
||||
selfNode: invalidChildNode,
|
||||
parentNode: parent,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
it('throws when node is neither a category or a script', () => {
|
||||
// arrange
|
||||
const expectedError = 'Node is neither a category or a script.';
|
||||
const invalidChildNode = { property: 'non-empty-value' } as never as CategoryOrScriptData;
|
||||
const parent = new CategoryDataStub()
|
||||
.withName('parent')
|
||||
.withChildren([new CategoryDataStub().withName('valid child'), invalidChildNode]);
|
||||
// act
|
||||
const test: ITestScenario = {
|
||||
// act
|
||||
act: () => new TestBuilder()
|
||||
.withData(parent)
|
||||
.parseCategory(),
|
||||
// assert
|
||||
expectedContext: {
|
||||
selfNode: invalidChildNode,
|
||||
parentNode: parent,
|
||||
},
|
||||
};
|
||||
// assert
|
||||
expectThrowsNodeError(test, expectedError);
|
||||
});
|
||||
describe('throws when category child is invalid category', () => {
|
||||
new NodeValidationTestRunner().testInvalidNodeName((invalidName) => {
|
||||
// arrange
|
||||
const invalidChildNode = new CategoryDataStub()
|
||||
.withName(invalidName);
|
||||
const parent = new CategoryDataStub()
|
||||
.withName('parent')
|
||||
.withChildren([new CategoryDataStub().withName('valid child'), invalidChildNode]);
|
||||
return ({
|
||||
// act
|
||||
act: () => new TestBuilder()
|
||||
.withData(parent)
|
||||
.parseCategory(),
|
||||
// assert
|
||||
expectedContext: {
|
||||
type: NodeType.Category,
|
||||
selfNode: invalidChildNode,
|
||||
parentNode: parent,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
function createTest(category: CategoryData): ITestScenario {
|
||||
return {
|
||||
act: () => new TestBuilder()
|
||||
.withData(category)
|
||||
.parseCategory(),
|
||||
expectedContext: {
|
||||
type: NodeType.Category,
|
||||
selfNode: category,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
describe('throws when category children is absent', () => {
|
||||
itEachAbsentCollectionValue((absentValue) => {
|
||||
// arrange
|
||||
const categoryName = 'test';
|
||||
const expectedMessage = `category has no children: "${categoryName}"`;
|
||||
const category = new CategoryDataStub()
|
||||
.withName(categoryName)
|
||||
.withChildren(absentValue);
|
||||
const context = new CategoryCollectionParseContextStub();
|
||||
// act
|
||||
const act = () => parseCategory(category, context);
|
||||
// assert
|
||||
expect(act).to.throw(expectedMessage);
|
||||
});
|
||||
});
|
||||
describe('throws when name is absent', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedMessage = 'category has no name';
|
||||
const category = new CategoryDataStub()
|
||||
.withName(absentValue);
|
||||
const context = new CategoryCollectionParseContextStub();
|
||||
// act
|
||||
const act = () => parseCategory(category, context);
|
||||
// assert
|
||||
expect(act).to.throw(expectedMessage);
|
||||
});
|
||||
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', () => {
|
||||
@@ -59,9 +146,10 @@ describe('CategoryParser', () => {
|
||||
// arrange
|
||||
const expectedError = 'missing context';
|
||||
const context = absentValue;
|
||||
const category = new CategoryDataStub();
|
||||
// act
|
||||
const act = () => parseCategory(category, context);
|
||||
const act = () => new TestBuilder()
|
||||
.withContext(context)
|
||||
.parseCategory();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
@@ -72,9 +160,11 @@ describe('CategoryParser', () => {
|
||||
const expected = parseDocUrls({ docs: url });
|
||||
const category = new CategoryDataStub()
|
||||
.withDocs(url);
|
||||
const context = new CategoryCollectionParseContextStub();
|
||||
// act
|
||||
const actual = parseCategory(category, context).documentationUrls;
|
||||
const actual = new TestBuilder()
|
||||
.withData(category)
|
||||
.parseCategory()
|
||||
.documentationUrls;
|
||||
// assert
|
||||
expect(actual).to.deep.equal(expected);
|
||||
});
|
||||
@@ -87,7 +177,11 @@ describe('CategoryParser', () => {
|
||||
const category = new CategoryDataStub()
|
||||
.withChildren([script]);
|
||||
// act
|
||||
const actual = parseCategory(category, context).scripts;
|
||||
const actual = new TestBuilder()
|
||||
.withData(category)
|
||||
.withContext(context)
|
||||
.parseCategory()
|
||||
.scripts;
|
||||
// assert
|
||||
expect(actual).to.deep.equal(expected);
|
||||
});
|
||||
@@ -102,7 +196,11 @@ describe('CategoryParser', () => {
|
||||
const category = new CategoryDataStub()
|
||||
.withChildren([script]);
|
||||
// act
|
||||
const actual = parseCategory(category, context).scripts;
|
||||
const actual = new TestBuilder()
|
||||
.withData(category)
|
||||
.withContext(context)
|
||||
.parseCategory()
|
||||
.scripts;
|
||||
// assert
|
||||
expect(actual).to.deep.equal(expected);
|
||||
});
|
||||
@@ -118,7 +216,11 @@ describe('CategoryParser', () => {
|
||||
.withCompiler(compiler);
|
||||
const expected = scripts.map((script) => parseScript(script, context));
|
||||
// act
|
||||
const actual = parseCategory(category, context).scripts;
|
||||
const actual = new TestBuilder()
|
||||
.withData(category)
|
||||
.withContext(context)
|
||||
.parseCategory()
|
||||
.scripts;
|
||||
// assert
|
||||
expect(actual).to.deep.equal(expected);
|
||||
});
|
||||
@@ -139,7 +241,11 @@ describe('CategoryParser', () => {
|
||||
]),
|
||||
]);
|
||||
// act
|
||||
const act = () => parseCategory(category, parseContext).scripts;
|
||||
const act = () => new TestBuilder()
|
||||
.withData(category)
|
||||
.withContext(parseContext)
|
||||
.parseCategory()
|
||||
.scripts;
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
});
|
||||
@@ -153,9 +259,11 @@ describe('CategoryParser', () => {
|
||||
const category = new CategoryDataStub()
|
||||
.withName('category name')
|
||||
.withChildren(expected);
|
||||
const context = new CategoryCollectionParseContextStub();
|
||||
// act
|
||||
const actual = parseCategory(category, context).subCategories;
|
||||
const actual = new TestBuilder()
|
||||
.withData(category)
|
||||
.parseCategory()
|
||||
.subCategories;
|
||||
// assert
|
||||
expect(actual).to.have.lengthOf(1);
|
||||
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 { 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 { RecommendationLevel } from '@/domain/RecommendationLevel';
|
||||
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 { ScriptDataStub } from '@tests/unit/shared/Stubs/ScriptDataStub';
|
||||
import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub';
|
||||
import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
|
||||
import { CategoryCollectionParseContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionParseContextStub';
|
||||
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('parseScript', () => {
|
||||
@@ -19,9 +24,10 @@ describe('ScriptParser', () => {
|
||||
const expected = 'test-expected-name';
|
||||
const script = ScriptDataStub.createWithCode()
|
||||
.withName(expected);
|
||||
const parseContext = new CategoryCollectionParseContextStub();
|
||||
// act
|
||||
const actual = parseScript(script, parseContext);
|
||||
const actual = new TestBuilder()
|
||||
.withData(script)
|
||||
.parseScript();
|
||||
// assert
|
||||
expect(actual.name).to.equal(expected);
|
||||
});
|
||||
@@ -30,86 +36,42 @@ describe('ScriptParser', () => {
|
||||
const docs = ['https://expected-doc1.com', 'https://expected-doc2.com'];
|
||||
const script = ScriptDataStub.createWithCode()
|
||||
.withDocs(docs);
|
||||
const parseContext = new CategoryCollectionParseContextStub();
|
||||
const expected = parseDocUrls(script);
|
||||
// act
|
||||
const actual = parseScript(script, parseContext);
|
||||
const actual = new TestBuilder()
|
||||
.withData(script)
|
||||
.parseScript();
|
||||
// assert
|
||||
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('accepts absent level', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const parseContext = new CategoryCollectionParseContextStub();
|
||||
const script = ScriptDataStub.createWithCode()
|
||||
.withRecommend(absentValue);
|
||||
// act
|
||||
const actual = parseScript(script, parseContext);
|
||||
const actual = new TestBuilder()
|
||||
.withData(script)
|
||||
.parseScript();
|
||||
// assert
|
||||
expect(actual.level).to.equal(undefined);
|
||||
});
|
||||
});
|
||||
describe('parses level as expected', () => {
|
||||
it('parses level as expected', () => {
|
||||
// arrange
|
||||
const expectedLevel = RecommendationLevel.Standard;
|
||||
const expectedName = 'level';
|
||||
const levelText = 'standard';
|
||||
const script = ScriptDataStub.createWithCode()
|
||||
.withRecommend(levelText);
|
||||
const parseContext = new CategoryCollectionParseContextStub();
|
||||
const parserMock = new EnumParserStub<RecommendationLevel>()
|
||||
.setup(expectedName, levelText, expectedLevel);
|
||||
// act
|
||||
const actual = parseScript(script, parseContext, parserMock);
|
||||
const actual = new TestBuilder()
|
||||
.withData(script)
|
||||
.withParser(parserMock)
|
||||
.parseScript();
|
||||
// assert
|
||||
expect(actual.level).to.equal(expectedLevel);
|
||||
});
|
||||
@@ -121,9 +83,10 @@ describe('ScriptParser', () => {
|
||||
const script = ScriptDataStub
|
||||
.createWithCode()
|
||||
.withCode(expected);
|
||||
const parseContext = new CategoryCollectionParseContextStub();
|
||||
// act
|
||||
const parsed = parseScript(script, parseContext);
|
||||
const parsed = new TestBuilder()
|
||||
.withData(script)
|
||||
.parseScript();
|
||||
// assert
|
||||
const actual = parsed.code.execute;
|
||||
expect(actual).to.equal(expected);
|
||||
@@ -134,9 +97,10 @@ describe('ScriptParser', () => {
|
||||
const script = ScriptDataStub
|
||||
.createWithCode()
|
||||
.withRevertCode(expected);
|
||||
const parseContext = new CategoryCollectionParseContextStub();
|
||||
// act
|
||||
const parsed = parseScript(script, parseContext);
|
||||
const parsed = new TestBuilder()
|
||||
.withData(script)
|
||||
.parseScript();
|
||||
// assert
|
||||
const actual = parsed.code.revert;
|
||||
expect(actual).to.equal(expected);
|
||||
@@ -146,10 +110,11 @@ describe('ScriptParser', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedMessage = 'missing context';
|
||||
const script = ScriptDataStub.createWithCode();
|
||||
const context: ICategoryCollectionParseContext = absentValue;
|
||||
// act
|
||||
const act = () => parseScript(script, context);
|
||||
const act = () => new TestBuilder()
|
||||
.withContext(context)
|
||||
.parseScript();
|
||||
// assert
|
||||
expect(act).to.throw(expectedMessage);
|
||||
});
|
||||
@@ -163,14 +128,17 @@ describe('ScriptParser', () => {
|
||||
const parseContext = new CategoryCollectionParseContextStub()
|
||||
.withCompiler(compiler);
|
||||
// act
|
||||
const parsed = parseScript(script, parseContext);
|
||||
const parsed = new TestBuilder()
|
||||
.withData(script)
|
||||
.withContext(parseContext)
|
||||
.parseScript();
|
||||
// assert
|
||||
const actual = parsed.code;
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
});
|
||||
describe('syntax', () => {
|
||||
it('set from the context', () => { // test through script validation logic
|
||||
it('set from the context', () => { // tests through script validation logic
|
||||
// arrange
|
||||
const commentDelimiter = 'should not throw';
|
||||
const duplicatedCode = `${commentDelimiter} duplicate-line\n${commentDelimiter} duplicate-line`;
|
||||
@@ -180,11 +148,112 @@ describe('ScriptParser', () => {
|
||||
.createWithoutCallOrCodes()
|
||||
.withCode(duplicatedCode);
|
||||
// act
|
||||
const act = () => parseScript(script, parseContext);
|
||||
const act = () => new TestBuilder()
|
||||
.withData(script)
|
||||
.withContext(parseContext);
|
||||
// assert
|
||||
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', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const name = 'script-name';
|
||||
const expectedError = `missing code (script: ${name})`;
|
||||
const expectedError = 'missing code';
|
||||
const code: IScriptCode = absentValue;
|
||||
// act
|
||||
const construct = () => new ScriptBuilder()
|
||||
.withName(name)
|
||||
.withCode(code)
|
||||
.build();
|
||||
// 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) {
|
||||
return scenario.outputValue;
|
||||
}
|
||||
if (this.defaultValue) {
|
||||
if (this.defaultValue !== undefined) {
|
||||
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 {
|
||||
itEachTestCase(AbsentStringTestCases, runner);
|
||||
itEachAbsentTestCase(AbsentStringTestCases, runner);
|
||||
}
|
||||
|
||||
export function itEachAbsentObjectValue(runner: (absentValue: AbsentObjectType) => void): void {
|
||||
itEachTestCase(AbsentObjectTestCases, runner);
|
||||
export function itEachAbsentObjectValue(
|
||||
runner: (absentValue: AbsentObjectType) => void,
|
||||
): void {
|
||||
itEachAbsentTestCase(AbsentObjectTestCases, runner);
|
||||
}
|
||||
|
||||
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>[],
|
||||
runner: (absentValue: T) => void,
|
||||
): void {
|
||||
@@ -53,8 +55,8 @@ export function getAbsentCollectionTestCases<T>(): readonly IAbsentCollectionCas
|
||||
type AbsentObjectType = undefined | null;
|
||||
|
||||
interface IAbsentTestCase<T> {
|
||||
valueName: string;
|
||||
absentValue: T;
|
||||
readonly valueName: string;
|
||||
readonly absentValue: T;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
|
||||
Reference in New Issue
Block a user