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

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