diff --git a/docs/application.md b/docs/application.md index ddd357ce..c2be5230 100644 --- a/docs/application.md +++ b/docs/application.md @@ -3,6 +3,14 @@ - It's mainly responsible for - creating and event based [application state](#application-state) - [parsing](#parsing) and [compiling](#compiling) [application data](#application-data) +- Consumed by [presentation layer](./presentation.md) + +## Structure + +- [`/src/` **`application/`**](./../src/application/): Contains all application related code. + - [**`collections/`**](./../src/application/collections/): Holds [collection files](./collection-files.md) + - [**`Common/`**](./../src/application/Common/): Contains common functionality that is shared in application layer. + - `..`: other classes are categorized using folders-by-feature structure ## Application state diff --git a/src/application/Common/Array.ts b/src/application/Common/Array.ts new file mode 100644 index 00000000..079fa41a --- /dev/null +++ b/src/application/Common/Array.ts @@ -0,0 +1,21 @@ +// Compares to Array objects for equality, ignoring order +export function scrambledEqual(array1: readonly T[], array2: readonly T[]) { + if (!array1) { throw new Error('undefined first array'); } + if (!array2) { throw new Error('undefined second array'); } + const sortedArray1 = sort(array1); + const sortedArray2 = sort(array2); + return sequenceEqual(sortedArray1, sortedArray2); + function sort(array: readonly T[]) { + return array.slice().sort(); + } +} + +// Compares to Array objects for equality in same order +export function sequenceEqual(array1: readonly T[], array2: readonly T[]) { + if (!array1) { throw new Error('undefined first array'); } + if (!array2) { throw new Error('undefined second array'); } + if (array1.length !== array2.length) { + return false; + } + return array1.every((val, index) => val === array2[index]); +} diff --git a/src/application/Common/Enum.ts b/src/application/Common/Enum.ts index a2039856..2042a7ac 100644 --- a/src/application/Common/Enum.ts +++ b/src/application/Common/Enum.ts @@ -1,6 +1,6 @@ // Because we cannot do "T extends enum" 😞 https://github.com/microsoft/TypeScript/issues/30611 -type EnumType = number | string; -type EnumVariable = { [key in T]: TEnumValue }; +export type EnumType = number | string; +export type EnumVariable = { [key in T]: TEnumValue }; export interface IEnumParser { parseEnum(value: string, propertyName: string): TEnum; @@ -41,3 +41,14 @@ export function getEnumValues( return getEnumNames(enumVariable) .map((level) => enumVariable[level]) as TEnumValue[]; } + +export function assertInRange( + value: TEnumValue, + enumVariable: EnumVariable) { + if (value === undefined) { + throw new Error('undefined enum value'); + } + if (!(value in enumVariable)) { + throw new RangeError(`enum value "${value}" is out of range`); + } +} diff --git a/src/application/Common/ScriptingLanguage/IScriptingLanguageFactory.ts b/src/application/Common/ScriptingLanguage/IScriptingLanguageFactory.ts new file mode 100644 index 00000000..b82df700 --- /dev/null +++ b/src/application/Common/ScriptingLanguage/IScriptingLanguageFactory.ts @@ -0,0 +1,5 @@ +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; + +export interface IScriptingLanguageFactory { + create(language: ScriptingLanguage): T; +} diff --git a/src/application/Common/ScriptingLanguage/ScriptingLanguageFactory.ts b/src/application/Common/ScriptingLanguage/ScriptingLanguageFactory.ts new file mode 100644 index 00000000..2b88c767 --- /dev/null +++ b/src/application/Common/ScriptingLanguage/ScriptingLanguageFactory.ts @@ -0,0 +1,31 @@ +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { IScriptingLanguageFactory } from './IScriptingLanguageFactory'; +import { assertInRange } from '@/application/Common/Enum'; + +type Getter = () => T; + +export abstract class ScriptingLanguageFactory implements IScriptingLanguageFactory { + private readonly getters = new Map>(); + + public create(language: ScriptingLanguage): T { + assertInRange(language, ScriptingLanguage); + if (!this.getters.has(language)) { + throw new RangeError(`unknown language: "${ScriptingLanguage[language]}"`); + } + const getter = this.getters.get(language); + const instance = getter(); + return instance; + } + + protected registerGetter(language: ScriptingLanguage, getter: Getter) { + assertInRange(language, ScriptingLanguage); + if (!getter) { + throw new Error('undefined getter'); + } + if (this.getters.has(language)) { + throw new Error(`${ScriptingLanguage[language]} is already registered`); + } + this.getters.set(language, getter); + } + +} diff --git a/src/application/Context/ApplicationContext.ts b/src/application/Context/ApplicationContext.ts index 19d30fb1..f4c401de 100644 --- a/src/application/Context/ApplicationContext.ts +++ b/src/application/Context/ApplicationContext.ts @@ -5,6 +5,7 @@ import { IApplication } from '@/domain/IApplication'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { EventSource } from '@/infrastructure/Events/EventSource'; +import { assertInRange } from '@/application/Common/Enum'; type StateMachine = Map; @@ -22,7 +23,7 @@ export class ApplicationContext implements IApplicationContext { public readonly app: IApplication, initialContext: OperatingSystem) { validateApp(app); - validateOs(initialContext); + assertInRange(initialContext, OperatingSystem); this.states = initializeStates(app); this.changeContext(initialContext); } @@ -50,18 +51,6 @@ function validateApp(app: IApplication) { } } -function validateOs(os: OperatingSystem) { - if (os === undefined) { - throw new Error('undefined os'); - } - if (os === OperatingSystem.Unknown) { - throw new Error('unknown os'); - } - if (!(os in OperatingSystem)) { - throw new Error(`os "${os}" is out of range`); - } -} - function initializeStates(app: IApplication): StateMachine { const machine = new Map(); for (const collection of app.collections) { diff --git a/src/application/Context/State/Code/Generation/CodeBuilderFactory.ts b/src/application/Context/State/Code/Generation/CodeBuilderFactory.ts index 040d21d2..c75b4714 100644 --- a/src/application/Context/State/Code/Generation/CodeBuilderFactory.ts +++ b/src/application/Context/State/Code/Generation/CodeBuilderFactory.ts @@ -1,15 +1,14 @@ +import { ScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/ScriptingLanguageFactory'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ICodeBuilder } from './ICodeBuilder'; -import { ICodeBuilderFactory } from './ICodeBuilderFactory'; import { BatchBuilder } from './Languages/BatchBuilder'; import { ShellBuilder } from './Languages/ShellBuilder'; +import { ICodeBuilderFactory } from './ICodeBuilderFactory'; -export class CodeBuilderFactory implements ICodeBuilderFactory { - public create(language: ScriptingLanguage): ICodeBuilder { - switch (language) { - case ScriptingLanguage.shellscript: return new ShellBuilder(); - case ScriptingLanguage.batchfile: return new BatchBuilder(); - default: throw new RangeError(`unknown language: "${ScriptingLanguage[language]}"`); - } +export class CodeBuilderFactory extends ScriptingLanguageFactory implements ICodeBuilderFactory { + constructor() { + super(); + this.registerGetter(ScriptingLanguage.shellscript, () => new ShellBuilder()); + this.registerGetter(ScriptingLanguage.batchfile, () => new BatchBuilder()); } } diff --git a/src/application/Context/State/Code/Generation/ICodeBuilderFactory.ts b/src/application/Context/State/Code/Generation/ICodeBuilderFactory.ts index 116ddb9c..15a7441b 100644 --- a/src/application/Context/State/Code/Generation/ICodeBuilderFactory.ts +++ b/src/application/Context/State/Code/Generation/ICodeBuilderFactory.ts @@ -1,6 +1,5 @@ -import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ICodeBuilder } from './ICodeBuilder'; +import { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory'; -export interface ICodeBuilderFactory { - create(language: ScriptingLanguage): ICodeBuilder; +export interface ICodeBuilderFactory extends IScriptingLanguageFactory { } diff --git a/src/application/Environment/BrowserOs/BrowserOsDetector.ts b/src/application/Environment/BrowserOs/BrowserOsDetector.ts index 10889a71..8fa5a1f8 100644 --- a/src/application/Environment/BrowserOs/BrowserOsDetector.ts +++ b/src/application/Environment/BrowserOs/BrowserOsDetector.ts @@ -4,17 +4,17 @@ import { IBrowserOsDetector } from './IBrowserOsDetector'; export class BrowserOsDetector implements IBrowserOsDetector { private readonly detectors = BrowserDetectors; - public detect(userAgent: string): OperatingSystem { + public detect(userAgent: string): OperatingSystem | undefined { if (!userAgent) { - return OperatingSystem.Unknown; + return undefined; } for (const detector of this.detectors) { const os = detector.detect(userAgent); - if (os !== OperatingSystem.Unknown) { + if (os !== undefined) { return os; } } - return OperatingSystem.Unknown; + return undefined; } } diff --git a/src/application/Environment/BrowserOs/DetectorBuilder.ts b/src/application/Environment/BrowserOs/DetectorBuilder.ts index 243e3332..3940ec40 100644 --- a/src/application/Environment/BrowserOs/DetectorBuilder.ts +++ b/src/application/Environment/BrowserOs/DetectorBuilder.ts @@ -29,10 +29,10 @@ export class DetectorBuilder { throw new Error('User agent is null or undefined'); } if (this.existingPartsInUserAgent.some((part) => !userAgent.includes(part))) { - return OperatingSystem.Unknown; + return undefined; } if (this.notExistingPartsInUserAgent.some((part) => userAgent.includes(part))) { - return OperatingSystem.Unknown; + return undefined; } return this.os; } diff --git a/src/application/Environment/BrowserOs/IBrowserOsDetector.ts b/src/application/Environment/BrowserOs/IBrowserOsDetector.ts index c327584e..d09e80ae 100644 --- a/src/application/Environment/BrowserOs/IBrowserOsDetector.ts +++ b/src/application/Environment/BrowserOs/IBrowserOsDetector.ts @@ -1,5 +1,5 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; export interface IBrowserOsDetector { - detect(userAgent: string): OperatingSystem; + detect(userAgent: string): OperatingSystem | undefined; } diff --git a/src/application/Environment/Environment.ts b/src/application/Environment/Environment.ts index c0fb8816..647afd06 100644 --- a/src/application/Environment/Environment.ts +++ b/src/application/Environment/Environment.ts @@ -44,7 +44,7 @@ function getProcessPlatform(variables: IEnvironmentVariables): string { return variables.process.platform; } -function getDesktopOsType(processPlatform: string): OperatingSystem { +function getDesktopOsType(processPlatform: string): OperatingSystem | undefined { // https://nodejs.org/api/process.html#process_process_platform if (processPlatform === 'darwin') { return OperatingSystem.macOS; @@ -53,7 +53,7 @@ function getDesktopOsType(processPlatform: string): OperatingSystem { } else if (processPlatform === 'linux') { return OperatingSystem.Linux; } - return OperatingSystem.Unknown; + return undefined; } function isDesktop(variables: IEnvironmentVariables): boolean { diff --git a/src/application/Parser/Script/Compiler/Function/SharedFunction.ts b/src/application/Parser/Script/Compiler/Function/SharedFunction.ts index ae9240ae..e1401f73 100644 --- a/src/application/Parser/Script/Compiler/Function/SharedFunction.ts +++ b/src/application/Parser/Script/Compiler/Function/SharedFunction.ts @@ -1,9 +1,10 @@ import { ISharedFunction } from './ISharedFunction'; export class SharedFunction implements ISharedFunction { + public readonly parameters: readonly string[]; constructor( public readonly name: string, - public readonly parameters: readonly string[], + parameters: readonly string[], public readonly code: string, public readonly revertCode: string, ) { diff --git a/src/application/Parser/Script/Syntax/ISyntaxFactory.ts b/src/application/Parser/Script/Syntax/ISyntaxFactory.ts index fd8bce14..aa65bace 100644 --- a/src/application/Parser/Script/Syntax/ISyntaxFactory.ts +++ b/src/application/Parser/Script/Syntax/ISyntaxFactory.ts @@ -1,6 +1,5 @@ import { ILanguageSyntax } from '@/domain/ScriptCode'; -import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory'; -export interface ISyntaxFactory { - create(language: ScriptingLanguage): ILanguageSyntax; +export interface ISyntaxFactory extends IScriptingLanguageFactory { } diff --git a/src/application/Parser/Script/Syntax/SyntaxFactory.ts b/src/application/Parser/Script/Syntax/SyntaxFactory.ts index e0d0f619..ac8c4219 100644 --- a/src/application/Parser/Script/Syntax/SyntaxFactory.ts +++ b/src/application/Parser/Script/Syntax/SyntaxFactory.ts @@ -1,15 +1,14 @@ import { ILanguageSyntax } from '@/domain/ScriptCode'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; -import { ISyntaxFactory } from './ISyntaxFactory'; +import { ScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/ScriptingLanguageFactory'; import { BatchFileSyntax } from './BatchFileSyntax'; import { ShellScriptSyntax } from './ShellScriptSyntax'; +import { ISyntaxFactory } from './ISyntaxFactory'; -export class SyntaxFactory implements ISyntaxFactory { - public create(language: ScriptingLanguage): ILanguageSyntax { - switch (language) { - case ScriptingLanguage.batchfile: return new BatchFileSyntax(); - case ScriptingLanguage.shellscript: return new ShellScriptSyntax(); - default: throw new RangeError(`unknown language: "${ScriptingLanguage[language]}"`); - } +export class SyntaxFactory extends ScriptingLanguageFactory implements ISyntaxFactory { + constructor() { + super(); + this.registerGetter(ScriptingLanguage.batchfile, () => new BatchFileSyntax()); + this.registerGetter(ScriptingLanguage.shellscript, () => new ShellScriptSyntax()); } } diff --git a/src/domain/CategoryCollection.ts b/src/domain/CategoryCollection.ts index 29b69dc3..a81910a8 100644 --- a/src/domain/CategoryCollection.ts +++ b/src/domain/CategoryCollection.ts @@ -1,4 +1,4 @@ -import { getEnumNames, getEnumValues } from '@/application/Common/Enum'; +import { getEnumNames, getEnumValues, assertInRange } from '@/application/Common/Enum'; import { IEntity } from '../infrastructure/Entity/IEntity'; import { ICategory } from './ICategory'; import { IScript } from './IScript'; @@ -21,7 +21,7 @@ export class CategoryCollection implements ICategoryCollection { throw new Error('undefined scripting definition'); } this.queryable = makeQueryable(actions); - ensureValidOs(os); + assertInRange(os, OperatingSystem); ensureValid(this.queryable); ensureNoDuplicates(this.queryable.allCategories); ensureNoDuplicates(this.queryable.allScripts); @@ -54,18 +54,6 @@ export class CategoryCollection implements ICategoryCollection { } } -function ensureValidOs(os: OperatingSystem): void { - if (os === undefined) { - throw new Error('undefined os'); - } - if (os === OperatingSystem.Unknown) { - throw new Error('unknown os'); - } - if (!(os in OperatingSystem)) { - throw new Error(`os "${os}" is out of range`); - } -} - function ensureNoDuplicates(entities: ReadonlyArray>) { const totalOccurrencesById = new Map(); for (const entity of entities) { diff --git a/src/domain/OperatingSystem.ts b/src/domain/OperatingSystem.ts index 4be31eda..01387674 100644 --- a/src/domain/OperatingSystem.ts +++ b/src/domain/OperatingSystem.ts @@ -10,5 +10,4 @@ export enum OperatingSystem { Android, iOS, WindowsPhone, - Unknown, } diff --git a/src/domain/ProjectInformation.ts b/src/domain/ProjectInformation.ts index 4ed0f6e3..570a098a 100644 --- a/src/domain/ProjectInformation.ts +++ b/src/domain/ProjectInformation.ts @@ -1,5 +1,6 @@ import { IProjectInformation } from './IProjectInformation'; import { OperatingSystem } from './OperatingSystem'; +import { assertInRange } from '@/application/Common/Enum'; export class ProjectInformation implements IProjectInformation { public readonly repositoryWebUrl: string; @@ -42,6 +43,7 @@ function getWebUrl(gitUrl: string) { } function getFileName(os: OperatingSystem, version: string): string { + assertInRange(os, OperatingSystem); switch (os) { case OperatingSystem.Linux: return `privacy.sexy-${version}.AppImage`; @@ -50,6 +52,6 @@ function getFileName(os: OperatingSystem, version: string): string { case OperatingSystem.Windows: return `privacy.sexy-Setup-${version}.exe`; default: - throw new Error(`Unsupported os: ${OperatingSystem[os]}`); + throw new RangeError(`Unsupported os: ${OperatingSystem[os]}`); } } diff --git a/src/domain/ScriptCode.ts b/src/domain/ScriptCode.ts index e5ff2b7f..d9698831 100644 --- a/src/domain/ScriptCode.ts +++ b/src/domain/ScriptCode.ts @@ -7,16 +7,7 @@ export class ScriptCode implements IScriptCode { syntax: ILanguageSyntax) { if (!syntax) { throw new Error('undefined syntax'); } validateCode(execute, syntax); - if (revert) { - try { - validateCode(revert, syntax); - if (execute === revert) { - throw new Error(`Code itself and its reverting code cannot be the same`); - } - } catch (err) { - throw Error(`(revert): ${err.message}`); - } - } + validateRevertCode(revert, execute, syntax); } } @@ -25,6 +16,20 @@ export interface ILanguageSyntax { readonly commonCodeParts: string[]; } +function validateRevertCode(revertCode: string, execute: string, syntax: ILanguageSyntax) { + if (!revertCode) { + return; + } + try { + validateCode(revertCode, syntax); + if (execute === revertCode) { + throw new Error(`Code itself and its reverting code cannot be the same`); + } + } catch (err) { + throw Error(`(revert): ${err.message}`); + } +} + function validateCode(code: string, syntax: ILanguageSyntax): void { if (!code || code.length === 0) { throw new Error(`code is empty or undefined`); diff --git a/src/infrastructure/CodeRunner.ts b/src/infrastructure/CodeRunner.ts index 15fb86ec..f687783b 100644 --- a/src/infrastructure/CodeRunner.ts +++ b/src/infrastructure/CodeRunner.ts @@ -5,16 +5,20 @@ import fs from 'fs'; import child_process from 'child_process'; import { OperatingSystem } from '@/domain/OperatingSystem'; -export async function runCodeAsync( - code: string, folderName: string, fileExtension: string, - node = getNodeJs(), environment = Environment.CurrentEnvironment): Promise { - const dir = node.path.join(node.os.tmpdir(), folderName); - await node.fs.promises.mkdir(dir, {recursive: true}); - const filePath = node.path.join(dir, `run.${fileExtension}`); - await node.fs.promises.writeFile(filePath, code); - await node.fs.promises.chmod(filePath, '755'); - const command = getExecuteCommand(filePath, environment); - node.child_process.exec(command); +export class CodeRunner { + constructor( + private readonly node = getNodeJs(), + private readonly environment = Environment.CurrentEnvironment) { + } + public async runCodeAsync(code: string, folderName: string, fileExtension: string): Promise { + const dir = this.node.path.join(this.node.os.tmpdir(), folderName); + await this.node.fs.promises.mkdir(dir, {recursive: true}); + const filePath = this.node.path.join(dir, `run.${fileExtension}`); + await this.node.fs.promises.writeFile(filePath, code); + await this.node.fs.promises.chmod(filePath, '755'); + const command = getExecuteCommand(filePath, this.environment); + this.node.child_process.exec(command); + } } function getExecuteCommand(scriptPath: string, environment: Environment): string { diff --git a/src/presentation/components/Code/CodeButtons/TheCodeButtons.vue b/src/presentation/components/Code/CodeButtons/TheCodeButtons.vue index fb90bff6..dd7b8716 100644 --- a/src/presentation/components/Code/CodeButtons/TheCodeButtons.vue +++ b/src/presentation/components/Code/CodeButtons/TheCodeButtons.vue @@ -37,7 +37,7 @@ import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import { runCodeAsync } from '@/infrastructure/CodeRunner'; +import { CodeRunner } from '@/infrastructure/CodeRunner'; import { IApplicationContext } from '@/application/Context/IApplicationContext'; @Component({ @@ -116,11 +116,12 @@ function buildFileName(scripting: IScriptingDefinition) { } async function executeCodeAsync(context: IApplicationContext) { - await runCodeAsync( - /*code*/ context.state.code.current, - /*appName*/ context.app.info.name, - /*fileExtension*/ context.state.collection.scripting.fileExtension, - ); + const runner = new CodeRunner(); + await runner.runCodeAsync( + /*code*/ context.state.code.current, + /*appName*/ context.app.info.name, + /*fileExtension*/ context.state.collection.scripting.fileExtension, + ); } diff --git a/src/presentation/components/Scripts/Menu/TheOsChanger.vue b/src/presentation/components/Scripts/Menu/TheOsChanger.vue index 86b71ede..6c3e2a49 100644 --- a/src/presentation/components/Scripts/Menu/TheOsChanger.vue +++ b/src/presentation/components/Scripts/Menu/TheOsChanger.vue @@ -24,7 +24,7 @@ import { ApplicationFactory } from '@/application/ApplicationFactory'; @Component export default class TheOsChanger extends StatefulVue { public allOses: Array<{ name: string, os: OperatingSystem }> = []; - public currentOs: OperatingSystem = OperatingSystem.Unknown; + public currentOs?: OperatingSystem = undefined; public async created() { const app = await ApplicationFactory.Current.getAppAsync(); diff --git a/tests/unit/application/Common/Array.ComparerTestScenario.ts b/tests/unit/application/Common/Array.ComparerTestScenario.ts new file mode 100644 index 00000000..382a62c8 --- /dev/null +++ b/tests/unit/application/Common/Array.ComparerTestScenario.ts @@ -0,0 +1,69 @@ +interface IComparerTestCase { + readonly name: string; + readonly first: readonly T[]; + readonly second: readonly T[]; + readonly expected: boolean; +} + +export class ComparerTestScenario { + private readonly testCases: Array> = []; + + public addEmptyArrays(expectedResult: boolean) { + return this.addTestCase({ + name: 'empty array', + first: [ ], + second: [ ], + expected: expectedResult, + }, true); + } + public addSameItemsWithSameOrder(expectedResult: boolean) { + return this.addTestCase({ + name: 'same items with same order', + first: [ 1, 2, 3 ], + second: [ 1, 2, 3 ], + expected: expectedResult, + }, true); + } + public addSameItemsWithDifferentOrder(expectedResult: boolean) { + return this.addTestCase({ + name: 'same items with different order', + first: [ 1, 2, 3 ], + second: [ 2, 3, 1 ], + expected: expectedResult, + }, true); + } + public addDifferentItemsWithSameLength(expectedResult: boolean) { + return this.addTestCase({ + name: 'different items with same length', + first: [ 1, 2, 3 ], + second: [ 4, 5, 6 ], + expected: expectedResult, + }, true); + } + public addDifferentItemsWithDifferentLength(expectedResult: boolean) { + return this.addTestCase({ + name: 'different items with different length', + first: [ 1, 2 ], + second: [ 3, 4, 5 ], + expected: expectedResult, + }, true); + } + public forEachCase(handler: (testCase: IComparerTestCase) => void) { + for (const testCase of this.testCases) { + handler(testCase); + } + } + + private addTestCase(testCase: IComparerTestCase, addReversed: boolean) { + this.testCases.push(testCase); + if (addReversed) { + this.testCases.push({ + name: `${testCase.name} (reversed)`, + first: testCase.second, + second: testCase.first, + expected: testCase.expected, + }); + } + return this; + } +} diff --git a/tests/unit/application/Common/Array.spec.ts b/tests/unit/application/Common/Array.spec.ts new file mode 100644 index 00000000..8ac14bf2 --- /dev/null +++ b/tests/unit/application/Common/Array.spec.ts @@ -0,0 +1,68 @@ +import 'mocha'; +import { expect } from 'chai'; +import { scrambledEqual } from '@/application/Common/Array'; +import { sequenceEqual } from '@/application/Common/Array'; +import { ComparerTestScenario } from './Array.ComparerTestScenario'; + +describe('Array', () => { + describe('scrambledEqual', () => { + describe('throws if arguments are undefined', () => { + it('first argument is undefined', () => { + const expectedError = 'undefined first array'; + const act = () => scrambledEqual(undefined, []); + expect(act).to.throw(expectedError); + }); + it('second arguments is undefined', () => { + const expectedError = 'undefined second array'; + const act = () => scrambledEqual([], undefined); + expect(act).to.throw(expectedError); + }); + }); + describe('returns as expected', () => { + // arrange + const scenario = new ComparerTestScenario() + .addSameItemsWithSameOrder(true) + .addSameItemsWithDifferentOrder(true) + .addDifferentItemsWithSameLength(false) + .addDifferentItemsWithDifferentLength(false); + // act + scenario.forEachCase((testCase) => { + it(testCase.name, () => { + const actual = scrambledEqual(testCase.first, testCase.second); + // assert + expect(actual).to.equal(testCase.expected); + }); + }); + }); + }); + describe('sequenceEqual', () => { + describe('throws if arguments are undefined', () => { + it('first argument is undefined', () => { + const expectedError = 'undefined first array'; + const act = () => sequenceEqual(undefined, []); + expect(act).to.throw(expectedError); + }); + it('second arguments is undefined', () => { + const expectedError = 'undefined second array'; + const act = () => sequenceEqual([], undefined); + expect(act).to.throw(expectedError); + }); + }); + describe('returns as expected', () => { + // arrange + const scenario = new ComparerTestScenario() + .addSameItemsWithSameOrder(true) + .addSameItemsWithDifferentOrder(true) + .addDifferentItemsWithSameLength(false) + .addDifferentItemsWithDifferentLength(false); + // act + scenario.forEachCase((testCase) => { + it(testCase.name, () => { + const actual = scrambledEqual(testCase.first, testCase.second); + // assert + expect(actual).to.equal(testCase.expected); + }); + }); + }); + }); +}); diff --git a/tests/unit/application/Common/Enum.spec.ts b/tests/unit/application/Common/Enum.spec.ts index 649716e7..fe276986 100644 --- a/tests/unit/application/Common/Enum.spec.ts +++ b/tests/unit/application/Common/Enum.spec.ts @@ -1,6 +1,8 @@ import 'mocha'; import { expect } from 'chai'; -import { getEnumNames, getEnumValues, createEnumParser } from '@/application/Common/Enum'; +import { getEnumNames, getEnumValues, createEnumParser, assertInRange } from '@/application/Common/Enum'; +import { EnumRangeTestRunner } from './EnumRangeTestRunner'; +import { scrambledEqual } from '@/application/Common/Array'; describe('Enum', () => { describe('createEnumParser', () => { @@ -78,7 +80,7 @@ describe('Enum', () => { // act const actual = getEnumNames(TestEnum); // assert - expect(expected.sort()).to.deep.equal(actual.sort()); + expect(scrambledEqual(expected, actual)); }); }); describe('getEnumValues', () => { @@ -89,7 +91,19 @@ describe('Enum', () => { // act const actual = getEnumValues(TestEnum); // assert - expect(expected.sort()).to.deep.equal(actual.sort()); + expect(scrambledEqual(expected, actual)); }); }); + describe('assertInRange', () => { + // arrange + enum TestEnum { Red, Green, Blue } + const validValue = TestEnum.Red; + // act + const act = (value: TestEnum) => assertInRange(value, TestEnum); + // assert + new EnumRangeTestRunner(act) + .testOutOfRangeThrows() + .testUndefinedValueThrows() + .testValidValueDoesNotThrow(validValue); + }); }); diff --git a/tests/unit/application/Common/EnumRangeTestRunner.ts b/tests/unit/application/Common/EnumRangeTestRunner.ts new file mode 100644 index 00000000..12400192 --- /dev/null +++ b/tests/unit/application/Common/EnumRangeTestRunner.ts @@ -0,0 +1,54 @@ +import 'mocha'; +import { expect } from 'chai'; +import { EnumType } from '@/application/Common/Enum'; + +export class EnumRangeTestRunner { + constructor(private readonly runner: (value: TEnumValue) => any) { + } + public testOutOfRangeThrows() { + it('throws when value is out of range', () => { + // arrange + const value = Number.MAX_SAFE_INTEGER as TEnumValue; + const expectedError = `enum value "${value}" is out of range`; + // act + const act = () => this.runner(value); + // assert + expect(act).to.throw(expectedError); + }); + return this; + } + public testUndefinedValueThrows() { + it('throws when value is undefined', () => { + // arrange + const value = undefined; + const expectedError = 'undefined enum value'; + // act + const act = () => this.runner(value); + // assert + expect(act).to.throw(expectedError); + }); + return this; + } + public testInvalidValueThrows(invalidValue: TEnumValue, expectedError: string) { + it(`throws ${expectedError}`, () => { + // arrange + const value = invalidValue; + // act + const act = () => this.runner(value); + // assert + expect(act).to.throw(expectedError); + }); + return this; + } + public testValidValueDoesNotThrow(validValue: TEnumValue) { + it('throws when value is undefined', () => { + // arrange + const value = validValue; + // act + const act = () => this.runner(value); + // assert + expect(act).to.not.throw(); + }); + return this; + } +} diff --git a/tests/unit/application/Common/ScriptingLanguage/ScriptingLanguageFactory.spec.ts b/tests/unit/application/Common/ScriptingLanguage/ScriptingLanguageFactory.spec.ts new file mode 100644 index 00000000..0bdc687b --- /dev/null +++ b/tests/unit/application/Common/ScriptingLanguage/ScriptingLanguageFactory.spec.ts @@ -0,0 +1,60 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { ScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/ScriptingLanguageFactory'; +import { ScriptingLanguageFactoryTestRunner } from './ScriptingLanguageFactoryTestRunner'; +import { EnumRangeTestRunner } from '../EnumRangeTestRunner'; + +class ScriptingLanguageConcrete extends ScriptingLanguageFactory { + public registerGetter(language: ScriptingLanguage, getter: () => number) { + super.registerGetter(language, getter); + } +} + +describe('ScriptingLanguageFactory', () => { + describe('registerGetter', () => { + describe('validates language', () => { + // arrange + const validValue = ScriptingLanguage.batchfile; + const getter = () => undefined; + const sut = new ScriptingLanguageConcrete(); + // act + const act = (language: ScriptingLanguage) => sut.registerGetter(language, getter); + // assert + new EnumRangeTestRunner(act) + .testOutOfRangeThrows() + .testUndefinedValueThrows() + .testValidValueDoesNotThrow(validValue); + }); + it('throw when getter is undefined', () => { + // arrange + const expectedError = `undefined getter`; + const language = ScriptingLanguage.batchfile; + const getter = undefined; + const sut = new ScriptingLanguageConcrete(); + // act + const act = () => sut.registerGetter(language, getter); + // assert + expect(act).to.throw(expectedError); + }); + it('throw when language is already registered', () => { + // arrange + const language = ScriptingLanguage.batchfile; + const expectedError = `${ScriptingLanguage[language]} is already registered`; + const getter = () => undefined; + const sut = new ScriptingLanguageConcrete(); + // act + sut.registerGetter(language, getter); + const reRegister = () => sut.registerGetter(language, getter); + // assert + expect(reRegister).to.throw(expectedError); + }); + }); + describe('create', () => { + const sut = new ScriptingLanguageConcrete(); + sut.registerGetter(ScriptingLanguage.batchfile, () => undefined); + const runner = new ScriptingLanguageFactoryTestRunner(); + runner.testCreateMethod(sut); + }); +}); + diff --git a/tests/unit/application/Common/ScriptingLanguage/ScriptingLanguageFactoryTestRunner.ts b/tests/unit/application/Common/ScriptingLanguage/ScriptingLanguageFactoryTestRunner.ts new file mode 100644 index 00000000..492e0b36 --- /dev/null +++ b/tests/unit/application/Common/ScriptingLanguage/ScriptingLanguageFactoryTestRunner.ts @@ -0,0 +1,48 @@ +import { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { expect } from 'chai'; +import { EnumRangeTestRunner } from '../EnumRangeTestRunner'; + +export class ScriptingLanguageFactoryTestRunner { + private expectedTypes = new Map(); + public expect(language: ScriptingLanguage, resultType: T) { + this.expectedTypes.set(language, resultType); + return this; + } + public testCreateMethod(sut: IScriptingLanguageFactory) { + if (!sut) { throw new Error('undefined sut'); } + testLanguageValidation(sut); + testExpectedInstanceTypes(sut, this.expectedTypes); + } +} + +function testExpectedInstanceTypes( + sut: IScriptingLanguageFactory, + expectedTypes: Map) { + describe('create returns expected instances', () => { + // arrange + for (const language of Array.from(expectedTypes.keys())) { + it(ScriptingLanguage[language], () => { + // act + const expected = expectedTypes.get(language); + const result = sut.create(language); + // assert + expect(result).to.be.instanceOf(expected, `Actual was: ${result.constructor.name}`); + }); + } + }); +} + +function testLanguageValidation(sut: IScriptingLanguageFactory) { + describe('validates language', () => { + // arrange + const validValue = ScriptingLanguage.batchfile; + // act + const act = (value: ScriptingLanguage) => sut.create(value); + // assert + new EnumRangeTestRunner(act) + .testOutOfRangeThrows() + .testUndefinedValueThrows() + .testValidValueDoesNotThrow(validValue); + }); +} diff --git a/tests/unit/application/Context/ApplicationContext.spec.ts b/tests/unit/application/Context/ApplicationContext.spec.ts index bf873b19..2fe3e50a 100644 --- a/tests/unit/application/Context/ApplicationContext.spec.ts +++ b/tests/unit/application/Context/ApplicationContext.spec.ts @@ -7,6 +7,7 @@ import { IApplicationContext, IApplicationContextChangedEvent } from '@/applicat import { IApplication } from '@/domain/IApplication'; import { ApplicationStub } from '../../stubs/ApplicationStub'; import { CategoryCollectionStub } from '../../stubs/CategoryCollectionStub'; +import { EnumRangeTestRunner } from '../Common/EnumRangeTestRunner'; describe('ApplicationContext', () => { describe('changeContext', () => { @@ -180,40 +181,15 @@ describe('ApplicationContext', () => { expect(actual).to.deep.equal(expected); }); describe('throws when OS is invalid', () => { - // arrange - const testCases = [ - { - name: 'out of range', - expectedError: 'os "9999" is out of range', - os: 9999, - }, - { - name: 'undefined', - expectedError: 'undefined os', - os: undefined, - }, - { - name: 'unknown', - expectedError: 'unknown os', - os: OperatingSystem.Unknown, - }, - { - name: 'does not exist in application', - expectedError: 'os "Android" is not defined in application', - os: OperatingSystem.Android, - }, - ]; // act - for (const testCase of testCases) { - it(testCase.name, () => { - const act = () => - new ObservableApplicationContextFactory() - .withInitialOs(testCase.os) - .construct(); - // assert - expect(act).to.throw(testCase.expectedError); - }); - } + const act = (os: OperatingSystem) => new ObservableApplicationContextFactory() + .withInitialOs(os) + .construct(); + // assert + new EnumRangeTestRunner(act) + .testOutOfRangeThrows() + .testUndefinedValueThrows() + .testInvalidValueThrows(OperatingSystem.Android, 'os "Android" is not defined in application'); }); }); describe('app', () => { diff --git a/tests/unit/application/Context/State/Code/Generation/CodeBuilderFactory.spec.ts b/tests/unit/application/Context/State/Code/Generation/CodeBuilderFactory.spec.ts index cd0b0a69..8580eaf3 100644 --- a/tests/unit/application/Context/State/Code/Generation/CodeBuilderFactory.spec.ts +++ b/tests/unit/application/Context/State/Code/Generation/CodeBuilderFactory.spec.ts @@ -1,36 +1,14 @@ import 'mocha'; -import { expect } from 'chai'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ShellBuilder } from '@/application/Context/State/Code/Generation/Languages/ShellBuilder'; import { BatchBuilder } from '@/application/Context/State/Code/Generation/Languages/BatchBuilder'; import { CodeBuilderFactory } from '@/application/Context/State/Code/Generation/CodeBuilderFactory'; +import { ScriptingLanguageFactoryTestRunner } from '../../../../Common/ScriptingLanguage/ScriptingLanguageFactoryTestRunner'; describe('CodeBuilderFactory', () => { - describe('create', () => { - describe('creates expected type', () => { - // arrange - const testCases: Array< { language: ScriptingLanguage, expected: any} > = [ - { language: ScriptingLanguage.shellscript, expected: ShellBuilder}, - { language: ScriptingLanguage.batchfile, expected: BatchBuilder}, - ]; - for (const testCase of testCases) { - it(ScriptingLanguage[testCase.language], () => { - // act - const sut = new CodeBuilderFactory(); - const result = sut.create(testCase.language); - // assert - expect(result).to.be.instanceOf(testCase.expected, - `Actual was: ${result.constructor.name}`); - }); - } - }); - it('throws on unknown scripting language', () => { - // arrange - const sut = new CodeBuilderFactory(); - // act - const act = () => sut.create(3131313131); - // assert - expect(act).to.throw(`unknown language: "${ScriptingLanguage[3131313131]}"`); - }); - }); + const sut = new CodeBuilderFactory(); + const runner = new ScriptingLanguageFactoryTestRunner() + .expect(ScriptingLanguage.shellscript, ShellBuilder) + .expect(ScriptingLanguage.batchfile, BatchBuilder); + runner.testCreateMethod(sut); }); diff --git a/tests/unit/application/Environment/BrowserOs/BrowserOsDetector.spec.ts b/tests/unit/application/Environment/BrowserOs/BrowserOsDetector.spec.ts index 5a46b6e7..fb2baded 100644 --- a/tests/unit/application/Environment/BrowserOs/BrowserOsDetector.spec.ts +++ b/tests/unit/application/Environment/BrowserOs/BrowserOsDetector.spec.ts @@ -4,13 +4,14 @@ import { BrowserOsDetector } from '@/application/Environment/BrowserOs/BrowserOs import { BrowserOsTestCases } from './BrowserOsTestCases'; describe('BrowserOsDetector', () => { - it('unkown when user agent is undefined', () => { + it('returns undefined when user agent is undefined', () => { // arrange + const expected = undefined; const sut = new BrowserOsDetector(); // act const actual = sut.detect(undefined); // assert - expect(actual).to.equal(OperatingSystem.Unknown); + expect(actual).to.equal(expected); }); it('detects as expected', () => { for (const testCase of BrowserOsTestCases) { diff --git a/tests/unit/application/Environment/DesktopOsTestCases.ts b/tests/unit/application/Environment/DesktopOsTestCases.ts index 620f5c0e..c1f09cdd 100644 --- a/tests/unit/application/Environment/DesktopOsTestCases.ts +++ b/tests/unit/application/Environment/DesktopOsTestCases.ts @@ -9,7 +9,7 @@ interface IDesktopTestCase { export const DesktopOsTestCases: ReadonlyArray = [ { processPlatform: 'aix', - expectedOs: OperatingSystem.Unknown, + expectedOs: undefined, }, { processPlatform: 'darwin', @@ -17,7 +17,7 @@ export const DesktopOsTestCases: ReadonlyArray = [ }, { processPlatform: 'freebsd', - expectedOs: OperatingSystem.Unknown, + expectedOs: undefined, }, { processPlatform: 'linux', @@ -25,11 +25,11 @@ export const DesktopOsTestCases: ReadonlyArray = [ }, { processPlatform: 'openbsd', - expectedOs: OperatingSystem.Unknown, + expectedOs: undefined, }, { processPlatform: 'sunos', - expectedOs: OperatingSystem.Unknown, + expectedOs: undefined, }, { processPlatform: 'win32', diff --git a/tests/unit/application/Parser/Script/Syntax/SyntaxFactory.spec.ts b/tests/unit/application/Parser/Script/Syntax/SyntaxFactory.spec.ts index 844a139f..c7bfa722 100644 --- a/tests/unit/application/Parser/Script/Syntax/SyntaxFactory.spec.ts +++ b/tests/unit/application/Parser/Script/Syntax/SyntaxFactory.spec.ts @@ -1,38 +1,14 @@ import 'mocha'; -import { expect } from 'chai'; import { SyntaxFactory } from '@/application/Parser/Script/Syntax/SyntaxFactory'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ShellScriptSyntax } from '@/application/Parser/Script/Syntax/ShellScriptSyntax'; import { BatchFileSyntax } from '@/application/Parser/Script/Syntax/BatchFileSyntax'; +import { ScriptingLanguageFactoryTestRunner } from '../../../Common/ScriptingLanguage/ScriptingLanguageFactoryTestRunner'; describe('SyntaxFactory', () => { - describe('getSyntax', () => { - describe('creates expected type', () => { - describe('shellscript returns ShellBuilder', () => { - // arrange - const testCases: Array< { language: ScriptingLanguage, expected: any} > = [ - { language: ScriptingLanguage.shellscript, expected: ShellScriptSyntax}, - { language: ScriptingLanguage.batchfile, expected: BatchFileSyntax}, - ]; - for (const testCase of testCases) { - it(ScriptingLanguage[testCase.language], () => { - // act - const sut = new SyntaxFactory(); - const result = sut.create(testCase.language); - // assert - expect(result).to.be.instanceOf(testCase.expected, - `Actual was: ${result.constructor.name}`); - }); - } - }); - }); - it('throws on unknown scripting language', () => { - // arrange - const sut = new SyntaxFactory(); - // act - const act = () => sut.create(3131313131); - // assert - expect(act).to.throw(`unknown language: "${ScriptingLanguage[3131313131]}"`); - }); - }); + const sut = new SyntaxFactory(); + const runner = new ScriptingLanguageFactoryTestRunner() + .expect(ScriptingLanguage.shellscript, ShellScriptSyntax) + .expect(ScriptingLanguage.batchfile, BatchFileSyntax); + runner.testCreateMethod(sut); }); diff --git a/tests/unit/domain/CategoryCollection.spec.ts b/tests/unit/domain/CategoryCollection.spec.ts index eb27a1ea..180118dd 100644 --- a/tests/unit/domain/CategoryCollection.spec.ts +++ b/tests/unit/domain/CategoryCollection.spec.ts @@ -9,6 +9,7 @@ import { getEnumValues } from '@/application/Common/Enum'; import { CategoryCollection } from '@/domain/CategoryCollection'; import { ScriptStub } from '../stubs/ScriptStub'; import { CategoryStub } from '../stubs/CategoryStub'; +import { EnumRangeTestRunner } from '../application/Common/EnumRangeTestRunner'; describe('CategoryCollection', () => { describe('getScriptsByLevel', () => { @@ -186,35 +187,15 @@ describe('CategoryCollection', () => { // assert expect(sut.os).to.deep.equal(expected); }); - it('cannot construct with unknown os', () => { - // arrange - const os = OperatingSystem.Unknown; + describe('throws when invalid', () => { // act - const construct = () => new CategoryCollectionBuilder() + const act = (os: OperatingSystem) => new CategoryCollectionBuilder() .withOs(os) .construct(); // assert - expect(construct).to.throw('unknown os'); - }); - it('cannot construct with undefined os', () => { - // arrange - const os = undefined; - // act - const construct = () => new CategoryCollectionBuilder() - .withOs(os) - .construct(); - // assert - expect(construct).to.throw('undefined os'); - }); - it('cannot construct with OS not in range', () => { - // arrange - const os: OperatingSystem = 666; - // act - const construct = () => new CategoryCollectionBuilder() - .withOs(os) - .construct(); - // assert - expect(construct).to.throw(`os "${os}" is out of range`); + new EnumRangeTestRunner(act) + .testOutOfRangeThrows() + .testUndefinedValueThrows(); }); }); describe('scriptingDefinition', () => { diff --git a/tests/unit/domain/ProjectInformation.spec.ts b/tests/unit/domain/ProjectInformation.spec.ts index 4e11219f..7b335ba7 100644 --- a/tests/unit/domain/ProjectInformation.spec.ts +++ b/tests/unit/domain/ProjectInformation.spec.ts @@ -2,6 +2,7 @@ import 'mocha'; import { expect } from 'chai'; import { ProjectInformation } from '@/domain/ProjectInformation'; import { OperatingSystem } from '@/domain/OperatingSystem'; +import { EnumRangeTestRunner } from '../application/Common/EnumRangeTestRunner'; describe('ProjectInformation', () => { it('sets name as expected', () => { @@ -115,14 +116,16 @@ describe('ProjectInformation', () => { // assert expect(actual).to.equal(expected); }); - it('throws when OS is unknown', () => { + describe('throws when os is invalid', () => { // arrange const sut = new ProjectInformation('name', 'version', 'repositoryUrl', 'homepage'); - const os = OperatingSystem.Unknown; // act - const act = () => sut.getDownloadUrl(os); + const act = (os: OperatingSystem) => sut.getDownloadUrl(os); // assert - expect(act).to.throw(`Unsupported os: ${OperatingSystem[os]}`); + new EnumRangeTestRunner(act) + .testOutOfRangeThrows() + .testUndefinedValueThrows() + .testInvalidValueThrows(OperatingSystem.KaiOS, `Unsupported os: ${OperatingSystem[OperatingSystem.KaiOS]}`); }); }); }); diff --git a/tests/unit/infrastructure/CodeRunner.spec.ts b/tests/unit/infrastructure/CodeRunner.spec.ts index 42352046..f4d10e0b 100644 --- a/tests/unit/infrastructure/CodeRunner.spec.ts +++ b/tests/unit/infrastructure/CodeRunner.spec.ts @@ -2,7 +2,7 @@ import { EnvironmentStub } from './../stubs/EnvironmentStub'; import { OperatingSystem } from '@/domain/OperatingSystem'; import 'mocha'; import { expect } from 'chai'; -import { runCodeAsync } from '@/infrastructure/CodeRunner'; +import { CodeRunner } from '@/infrastructure/CodeRunner'; describe('CodeRunner', () => { describe('runCodeAsync', () => { @@ -127,7 +127,8 @@ class TestContext { private env = mockEnvironment(OperatingSystem.Windows); public async runCodeAsync(): Promise { - await runCodeAsync(this.code, this.folderName, this.fileExtension, this.mocks, this.env); + const runner = new CodeRunner(this.mocks, this.env); + await runner.runCodeAsync(this.code, this.folderName, this.fileExtension); } public withOs(os: OperatingSystem) { this.env = mockEnvironment(os); diff --git a/tests/unit/stubs/FunctionCompilerStub.ts b/tests/unit/stubs/FunctionCompilerStub.ts index aef7e15f..ca9143a1 100644 --- a/tests/unit/stubs/FunctionCompilerStub.ts +++ b/tests/unit/stubs/FunctionCompilerStub.ts @@ -1,3 +1,4 @@ +import { sequenceEqual } from '@/application/Common/Array'; import { IFunctionCompiler } from '@/application/Parser/Script/Compiler/Function/IFunctionCompiler'; import { ISharedFunctionCollection } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionCollection'; import { FunctionData } from 'js-yaml-loader!*'; @@ -27,15 +28,3 @@ export class FunctionCompilerStub implements IFunctionCompiler { return undefined; } } - -function sequenceEqual(array1: readonly T[], array2: readonly T[]) { - if (array1.length !== array2.length) { - return false; - } - const sortedArray1 = sort(array1); - const sortedArray2 = sort(array2); - return sortedArray1.every((val, index) => val === sortedArray2[index]); - function sort(array: readonly T[]) { - return array.slice().sort(); - } -}