diff --git a/README.md b/README.md index b433bf8b..994d3b3a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # privacy.sexy -> Enforce privacy & security best-practices on Windows, because privacy is sexy 🍑🍆 +> Enforce privacy & security best-practices on Windows and macOS, because privacy is sexy 🍑🍆 [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](./CONTRIBUTING.md) [![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/undergroundwires/privacy.sexy.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/undergroundwires/privacy.sexy/context:javascript) diff --git a/package-lock.json b/package-lock.json index a160a030..3e51be5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "privacy.sexy", - "version": "0.8.1", + "version": "0.8.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index edf8d66f..bef4564a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "privacy.sexy", "version": "0.8.2", "author": "undergroundwires", - "description": "Enforce privacy & security best-practices on Windows, because privacy is sexy 🍑🍆", + "description": "Enforce privacy & security best-practices on Windows and macOS, because privacy is sexy 🍑🍆", "homepage": "https://privacy.sexy", "private": true, "repository": { diff --git a/public/index.html b/public/index.html index 530f2b0d..3223b92e 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,7 @@ - Privacy is sexy 🍑🍆 - Enforce privacy & security on Windows + Privacy is sexy 🍑🍆 - Enforce privacy & security on Windows and macOS diff --git a/src/App.vue b/src/App.vue index f66297e9..83ce3ec6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,7 +16,7 @@ import { Component, Vue } from 'vue-property-decorator'; import TheHeader from '@/presentation/TheHeader.vue'; import TheFooter from '@/presentation/TheFooter/TheFooter.vue'; import TheCodeArea from '@/presentation/TheCodeArea.vue'; -import TheCodeButtons from '@/presentation/TheCodeButtons.vue'; +import TheCodeButtons from '@/presentation/CodeButtons/TheCodeButtons.vue'; import TheSearchBar from '@/presentation/TheSearchBar.vue'; import TheScripts from '@/presentation/Scripts/TheScripts.vue'; diff --git a/src/application/Context/ApplicationContext.ts b/src/application/Context/ApplicationContext.ts index 9b15acfa..de3e9b68 100644 --- a/src/application/Context/ApplicationContext.ts +++ b/src/application/Context/ApplicationContext.ts @@ -36,9 +36,8 @@ export class ApplicationContext implements IApplicationContext { throw new Error(`os "${OperatingSystem[os]}" is not defined in application`); } const event: IApplicationContextChangedEvent = { - newState: this.state, - newCollection: this.collection, - newOs: os, + newState: this.states[os], + oldState: this.states[this.currentOs], }; this.contextChanged.notify(event); this.currentOs = os; diff --git a/src/application/Context/ApplicationContextProvider.ts b/src/application/Context/ApplicationContextProvider.ts index 05f91eec..1a8bb4ff 100644 --- a/src/application/Context/ApplicationContextProvider.ts +++ b/src/application/Context/ApplicationContextProvider.ts @@ -24,9 +24,8 @@ function getInitialOs(app: IApplication, environment: IEnvironment): OperatingSy return currentOs; } supportedOsList.sort((os1, os2) => { - const os1SupportLevel = app.collections[os1].totalScripts; - const os2SupportLevel = app.collections[os2].totalScripts; - return os1SupportLevel - os2SupportLevel; + const getPriority = (os: OperatingSystem) => app.getCollection(os).totalScripts; + return getPriority(os2) - getPriority(os1); }); return supportedOsList[0]; } diff --git a/src/application/Context/IApplicationContext.ts b/src/application/Context/IApplicationContext.ts index 24a6d66b..ff5a943e 100644 --- a/src/application/Context/IApplicationContext.ts +++ b/src/application/Context/IApplicationContext.ts @@ -1,13 +1,10 @@ import { ICategoryCollectionState } from './State/ICategoryCollectionState'; -import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { ISignal } from '@/infrastructure/Events/ISignal'; import { IApplication } from '@/domain/IApplication'; export interface IApplicationContext { - readonly currentOs: OperatingSystem; readonly app: IApplication; - readonly collection: ICategoryCollection; readonly state: ICategoryCollectionState; readonly contextChanged: ISignal; changeContext(os: OperatingSystem): void; @@ -15,6 +12,5 @@ export interface IApplicationContext { export interface IApplicationContextChangedEvent { readonly newState: ICategoryCollectionState; - readonly newCollection: ICategoryCollection; - readonly newOs: OperatingSystem; + readonly oldState: ICategoryCollectionState; } diff --git a/src/application/Context/State/CategoryCollectionState.ts b/src/application/Context/State/CategoryCollectionState.ts index ee30e8af..0f31539e 100644 --- a/src/application/Context/State/CategoryCollectionState.ts +++ b/src/application/Context/State/CategoryCollectionState.ts @@ -6,8 +6,10 @@ import { IUserSelection } from './Selection/IUserSelection'; import { ICategoryCollectionState } from './ICategoryCollectionState'; import { IApplicationCode } from './Code/IApplicationCode'; import { ICategoryCollection } from '../../../domain/ICategoryCollection'; +import { OperatingSystem } from '@/domain/OperatingSystem'; export class CategoryCollectionState implements ICategoryCollectionState { + public readonly os: OperatingSystem; public readonly code: IApplicationCode; public readonly selection: IUserSelection; public readonly filter: IUserFilter; @@ -16,5 +18,6 @@ export class CategoryCollectionState implements ICategoryCollectionState { this.selection = new UserSelection(collection, []); this.code = new ApplicationCode(this.selection, collection.scripting); this.filter = new UserFilter(collection); + this.os = collection.os; } } diff --git a/src/application/Context/State/Code/Generation/CodeBuilder.ts b/src/application/Context/State/Code/Generation/CodeBuilder.ts index 5354035e..601352e1 100644 --- a/src/application/Context/State/Code/Generation/CodeBuilder.ts +++ b/src/application/Context/State/Code/Generation/CodeBuilder.ts @@ -3,7 +3,7 @@ import { ICodeBuilder } from './ICodeBuilder'; const NewLine = '\n'; const TotalFunctionSeparatorChars = 58; -export class CodeBuilder implements ICodeBuilder { +export abstract class CodeBuilder implements ICodeBuilder { private readonly lines = new Array(); // Returns current line starting from 0 (no lines), or 1 (have single line) @@ -29,7 +29,7 @@ export class CodeBuilder implements ICodeBuilder { } public appendCommentLine(commentLine?: string): CodeBuilder { - this.lines.push(`:: ${commentLine}`); + this.lines.push(`${this.getCommentDelimiter()} ${commentLine}`); return this; } @@ -37,9 +37,8 @@ export class CodeBuilder implements ICodeBuilder { if (!name) { throw new Error('name cannot be empty or null'); } if (!code) { throw new Error('code cannot be empty or null'); } return this - .appendLine() .appendCommentLineWithHyphensAround(name) - .appendLine(`echo --- ${name}`) + .appendLine(this.writeStandardOut(`--- ${name}`)) .appendLine(code) .appendTrailingHyphensCommentLine(); } @@ -62,4 +61,7 @@ export class CodeBuilder implements ICodeBuilder { public toString(): string { return this.lines.join(NewLine); } + + protected abstract getCommentDelimiter(): string; + protected abstract writeStandardOut(text: string): string; } diff --git a/src/application/Context/State/Code/Generation/CodeBuilderFactory.ts b/src/application/Context/State/Code/Generation/CodeBuilderFactory.ts new file mode 100644 index 00000000..040d21d2 --- /dev/null +++ b/src/application/Context/State/Code/Generation/CodeBuilderFactory.ts @@ -0,0 +1,15 @@ +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { ICodeBuilder } from './ICodeBuilder'; +import { ICodeBuilderFactory } from './ICodeBuilderFactory'; +import { BatchBuilder } from './Languages/BatchBuilder'; +import { ShellBuilder } from './Languages/ShellBuilder'; + +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]}"`); + } + } +} diff --git a/src/application/Context/State/Code/Generation/ICodeBuilderFactory.ts b/src/application/Context/State/Code/Generation/ICodeBuilderFactory.ts new file mode 100644 index 00000000..116ddb9c --- /dev/null +++ b/src/application/Context/State/Code/Generation/ICodeBuilderFactory.ts @@ -0,0 +1,6 @@ +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { ICodeBuilder } from './ICodeBuilder'; + +export interface ICodeBuilderFactory { + create(language: ScriptingLanguage): ICodeBuilder; +} diff --git a/src/application/Context/State/Code/Generation/Languages/BatchBuilder.ts b/src/application/Context/State/Code/Generation/Languages/BatchBuilder.ts new file mode 100644 index 00000000..7a3668ef --- /dev/null +++ b/src/application/Context/State/Code/Generation/Languages/BatchBuilder.ts @@ -0,0 +1,10 @@ +import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder'; + +export class BatchBuilder extends CodeBuilder { + protected getCommentDelimiter(): string { + return '::'; + } + protected writeStandardOut(text: string): string { + return `echo ${text}`; + } +} diff --git a/src/application/Context/State/Code/Generation/Languages/ShellBuilder.ts b/src/application/Context/State/Code/Generation/Languages/ShellBuilder.ts new file mode 100644 index 00000000..b8209569 --- /dev/null +++ b/src/application/Context/State/Code/Generation/Languages/ShellBuilder.ts @@ -0,0 +1,10 @@ +import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder'; + +export class ShellBuilder extends CodeBuilder { + protected getCommentDelimiter(): string { + return '#'; + } + protected writeStandardOut(text: string): string { + return `echo '${text}'`; + } +} diff --git a/src/application/Context/State/Code/Generation/UserScriptGenerator.ts b/src/application/Context/State/Code/Generation/UserScriptGenerator.ts index 4fe13583..e3edd277 100644 --- a/src/application/Context/State/Code/Generation/UserScriptGenerator.ts +++ b/src/application/Context/State/Code/Generation/UserScriptGenerator.ts @@ -1,14 +1,15 @@ import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { IUserScriptGenerator } from './IUserScriptGenerator'; -import { CodeBuilder } from './CodeBuilder'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import { CodePosition } from '../Position/CodePosition'; import { IUserScript } from './IUserScript'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { ICodeBuilder } from './ICodeBuilder'; +import { ICodeBuilderFactory } from './ICodeBuilderFactory'; +import { CodeBuilderFactory } from './CodeBuilderFactory'; export class UserScriptGenerator implements IUserScriptGenerator { - constructor(private readonly codeBuilderFactory: () => ICodeBuilder = () => new CodeBuilder()) { + constructor(private readonly codeBuilderFactory: ICodeBuilderFactory = new CodeBuilderFactory()) { } public buildCode( @@ -20,7 +21,7 @@ export class UserScriptGenerator implements IUserScriptGenerator { if (!selectedScripts.length) { return { code: '', scriptPositions }; } - let builder = this.codeBuilderFactory(); + let builder = this.codeBuilderFactory.create(scriptingDefinition.language); builder = initializeCode(scriptingDefinition.startCode, builder); for (const selection of selectedScripts) { scriptPositions = appendSelection(selection, scriptPositions, builder); @@ -52,16 +53,19 @@ function appendSelection( selection: SelectedScript, scriptPositions: Map, builder: ICodeBuilder): Map { - const startPosition = builder.currentLine + 1; - appendCode(selection, builder); + const startPosition = builder.currentLine + 1; // Because first line will be empty to separate scripts + builder = appendCode(selection, builder); const endPosition = builder.currentLine - 1; builder.appendLine(); - scriptPositions.set(selection, new CodePosition(startPosition, endPosition)); + const position = new CodePosition(startPosition, endPosition); + scriptPositions.set(selection, position); return scriptPositions; } -function appendCode(selection: SelectedScript, builder: ICodeBuilder) { +function appendCode(selection: SelectedScript, builder: ICodeBuilder): ICodeBuilder { const name = selection.revert ? `${selection.script.name} (revert)` : selection.script.name; const scriptCode = selection.revert ? selection.script.code.revert : selection.script.code.execute; - builder.appendFunction(name, scriptCode); + return builder + .appendLine() + .appendFunction(name, scriptCode); } diff --git a/src/application/Context/State/Code/Position/CodePosition.ts b/src/application/Context/State/Code/Position/CodePosition.ts index ad2a9451..c0921989 100644 --- a/src/application/Context/State/Code/Position/CodePosition.ts +++ b/src/application/Context/State/Code/Position/CodePosition.ts @@ -1,6 +1,6 @@ import { ICodePosition } from './ICodePosition'; -export class CodePosition implements ICodePosition { +export class CodePosition implements ICodePosition { public get totalLines(): number { return this.endLine - this.startLine; } diff --git a/src/application/Context/State/Filter/IUserFilter.ts b/src/application/Context/State/Filter/IUserFilter.ts index 212b333d..e87e6f6f 100644 --- a/src/application/Context/State/Filter/IUserFilter.ts +++ b/src/application/Context/State/Filter/IUserFilter.ts @@ -1,5 +1,5 @@ +import { ISignal } from '@/infrastructure/Events/ISignal'; import { IFilterResult } from './IFilterResult'; -import { ISignal } from '@/infrastructure/Events/Signal'; export interface IUserFilter { readonly currentFilter: IFilterResult | undefined; diff --git a/src/application/Context/State/ICategoryCollectionState.ts b/src/application/Context/State/ICategoryCollectionState.ts index 21482f8f..af846bc6 100644 --- a/src/application/Context/State/ICategoryCollectionState.ts +++ b/src/application/Context/State/ICategoryCollectionState.ts @@ -1,10 +1,13 @@ import { IUserFilter } from './Filter/IUserFilter'; import { IUserSelection } from './Selection/IUserSelection'; import { IApplicationCode } from './Code/IApplicationCode'; -export { IUserSelection, IApplicationCode, IUserFilter }; +import { ICategoryCollection } from '@/domain/ICategoryCollection'; +import { OperatingSystem } from '@/domain/OperatingSystem'; export interface ICategoryCollectionState { readonly code: IApplicationCode; readonly filter: IUserFilter; readonly selection: IUserSelection; + readonly collection: ICategoryCollection; + readonly os: OperatingSystem; } diff --git a/src/application/Context/State/Selection/IUserSelection.ts b/src/application/Context/State/Selection/IUserSelection.ts index a817f6e9..c8f95599 100644 --- a/src/application/Context/State/Selection/IUserSelection.ts +++ b/src/application/Context/State/Selection/IUserSelection.ts @@ -1,7 +1,7 @@ import { SelectedScript } from './SelectedScript'; -import { ISignal } from '@/infrastructure/Events/Signal'; import { IScript } from '@/domain/IScript'; import { ICategory } from '@/domain/ICategory'; +import { ISignal } from '@/infrastructure/Events/ISignal'; export interface IUserSelection { readonly changed: ISignal>; diff --git a/src/application/Parser/ApplicationParser.ts b/src/application/Parser/ApplicationParser.ts index 2803304f..28f6464e 100644 --- a/src/application/Parser/ApplicationParser.ts +++ b/src/application/Parser/ApplicationParser.ts @@ -3,6 +3,7 @@ import { IProjectInformation } from '@/domain/IProjectInformation'; import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { parseCategoryCollection } from './CategoryCollectionParser'; import WindowsData from 'js-yaml-loader!@/application/collections/windows.yaml'; +import MacOsData from 'js-yaml-loader!@/application/collections/macos.yaml'; import { CollectionData } from 'js-yaml-loader!@/*'; import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser'; import { Application } from '@/domain/Application'; @@ -10,10 +11,11 @@ import { Application } from '@/domain/Application'; export function parseApplication( parser = CategoryCollectionParser, processEnv: NodeJS.ProcessEnv = process.env, - collectionData = LoadedCollectionData): IApplication { + collectionsData = PreParsedCollections): IApplication { + validateCollectionsData(collectionsData); const information = parseProjectInformation(processEnv); - const collection = parser(collectionData, information); - const app = new Application(information, [ collection ]); + const collections = collectionsData.map((collection) => parser(collection, information)); + const app = new Application(information, collections); return app; } @@ -23,5 +25,14 @@ export type CategoryCollectionParserType const CategoryCollectionParser: CategoryCollectionParserType = (file, info) => parseCategoryCollection(file, info); -const LoadedCollectionData: CollectionData - = WindowsData; +const PreParsedCollections: readonly CollectionData [] + = [ WindowsData, MacOsData ]; + +function validateCollectionsData(collections: readonly CollectionData[]) { + if (!collections.length) { + throw new Error('no collection provided'); + } + if (collections.some((collection) => !collection)) { + throw new Error('undefined collection provided'); + } +} diff --git a/src/application/Parser/CategoryCollectionParser.ts b/src/application/Parser/CategoryCollectionParser.ts index 74a4697a..373e871c 100644 --- a/src/application/Parser/CategoryCollectionParser.ts +++ b/src/application/Parser/CategoryCollectionParser.ts @@ -1,27 +1,27 @@ import { Category } from '@/domain/Category'; import { CollectionData } from 'js-yaml-loader!@/*'; import { parseCategory } from './CategoryParser'; -import { ScriptCompiler } from './Compiler/ScriptCompiler'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { parseScriptingDefinition } from './ScriptingDefinitionParser'; import { createEnumParser } from '../Common/Enum'; import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { CategoryCollection } from '@/domain/CategoryCollection'; import { IProjectInformation } from '@/domain/IProjectInformation'; +import { CategoryCollectionParseContext } from './Script/CategoryCollectionParseContext'; export function parseCategoryCollection( content: CollectionData, info: IProjectInformation, osParser = createEnumParser(OperatingSystem)): ICategoryCollection { validate(content); - const compiler = new ScriptCompiler(content.functions); + const scripting = parseScriptingDefinition(content.scripting, info); + const context = new CategoryCollectionParseContext(content.functions, scripting); const categories = new Array(); for (const action of content.actions) { - const category = parseCategory(action, compiler); + const category = parseCategory(action, context); categories.push(category); } const os = osParser.parseEnum(content.os, 'os'); - const scripting = parseScriptingDefinition(content.scripting, info); const collection = new CategoryCollection( os, categories, diff --git a/src/application/Parser/CategoryParser.ts b/src/application/Parser/CategoryParser.ts index ee0d8ac3..2ccaadb4 100644 --- a/src/application/Parser/CategoryParser.ts +++ b/src/application/Parser/CategoryParser.ts @@ -2,8 +2,8 @@ import { CategoryData, ScriptData, CategoryOrScriptData } from 'js-yaml-loader!@ import { Script } from '@/domain/Script'; import { Category } from '@/domain/Category'; import { parseDocUrls } from './DocumentationParser'; -import { parseScript } from './ScriptParser'; -import { IScriptCompiler } from './Compiler/IScriptCompiler'; +import { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext'; +import { parseScript } from './Script/ScriptParser'; let categoryIdCounter: number = 0; @@ -12,17 +12,15 @@ interface ICategoryChildren { subScripts: Script[]; } -export function parseCategory(category: CategoryData, compiler: IScriptCompiler): Category { - if (!compiler) { - throw new Error('undefined compiler'); - } +export function parseCategory(category: CategoryData, context: ICategoryCollectionParseContext): Category { + if (!context) { throw new Error('undefined context'); } ensureValid(category); const children: ICategoryChildren = { subCategories: new Array(), subScripts: new Array + + diff --git a/src/presentation/IconButton.vue b/src/presentation/CodeButtons/IconButton.vue similarity index 89% rename from src/presentation/IconButton.vue rename to src/presentation/CodeButtons/IconButton.vue index f6c60190..631aae99 100644 --- a/src/presentation/IconButton.vue +++ b/src/presentation/CodeButtons/IconButton.vue @@ -8,11 +8,10 @@ diff --git a/src/presentation/CodeButtons/MacOsInstructions.vue b/src/presentation/CodeButtons/MacOsInstructions.vue new file mode 100644 index 00000000..07f6fae9 --- /dev/null +++ b/src/presentation/CodeButtons/MacOsInstructions.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/src/presentation/CodeButtons/TheCodeButtons.vue b/src/presentation/CodeButtons/TheCodeButtons.vue new file mode 100644 index 00000000..e22ed309 --- /dev/null +++ b/src/presentation/CodeButtons/TheCodeButtons.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/src/presentation/Scripts/Cards/CardList.vue b/src/presentation/Scripts/Cards/CardList.vue index d61e7f97..f39c45e4 100644 --- a/src/presentation/Scripts/Cards/CardList.vue +++ b/src/presentation/Scripts/Cards/CardList.vue @@ -21,6 +21,8 @@ import CardListItem from './CardListItem.vue'; import { StatefulVue } from '@/presentation/StatefulVue'; import { ICategory } from '@/domain/ICategory'; import { hasDirective } from './NonCollapsingDirective'; +import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; +import { IApplication } from '@/domain/IApplication'; @Component({ components: { @@ -31,9 +33,7 @@ export default class CardList extends StatefulVue { public categoryIds: number[] = []; public activeCategoryId?: number = null; - public async mounted() { - const context = await this.getCurrentContextAsync(); - this.setCategories(context.collection.actions); + public created() { this.onOutsideOfActiveCardClicked((element) => { if (hasDirective(element)) { return; @@ -41,15 +41,21 @@ export default class CardList extends StatefulVue { this.activeCategoryId = null; }); } - public onSelected(categoryId: number, isExpanded: boolean) { this.activeCategoryId = isExpanded ? categoryId : undefined; } + protected initialize(app: IApplication): void { + return; + } + protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void { + this.setCategories(newState.collection.actions); + this.activeCategoryId = undefined; + } + private setCategories(categories: ReadonlyArray): void { this.categoryIds = categories.map((category) => category.id); } - private onOutsideOfActiveCardClicked(callback: (clickedElement: Element) => void) { const outsideClickListener = (event) => { if (!this.activeCategoryId) { diff --git a/src/presentation/Scripts/Cards/CardListItem.vue b/src/presentation/Scripts/Cards/CardListItem.vue index 092752c6..31639a8a 100644 --- a/src/presentation/Scripts/Cards/CardListItem.vue +++ b/src/presentation/Scripts/Cards/CardListItem.vue @@ -49,16 +49,17 @@ export default class CardListItem extends StatefulVue { public isAnyChildSelected = false; public areAllChildrenSelected = false; -@Emit('selected') + public async mounted() { + this.updateStateAsync(this.categoryId); + } + @Emit('selected') public onSelected(isExpanded: boolean) { this.isExpanded = isExpanded; } - @Watch('activeCategoryId') public async onActiveCategoryChanged(value: |number) { this.isExpanded = value === this.categoryId; } - @Watch('isExpanded') public async onExpansionChangedAsync(newValue: number, oldValue: number) { if (!oldValue && newValue) { @@ -67,24 +68,22 @@ export default class CardListItem extends StatefulVue { (focusElement as HTMLElement).scrollIntoView({behavior: 'smooth'}); } } - - public async mounted() { - const context = await this.getCurrentContextAsync(); - context.state.selection.changed.on(() => { - this.updateStateAsync(this.categoryId); - }); - this.updateStateAsync(this.categoryId); - } - @Watch('categoryId') public async updateStateAsync(value: |number) { const context = await this.getCurrentContextAsync(); - const category = !value ? undefined : context.collection.findCategory(this.categoryId); + const category = !value ? undefined : context.state.collection.findCategory(this.categoryId); this.cardTitle = category ? category.name : undefined; const currentSelection = context.state.selection; this.isAnyChildSelected = category ? currentSelection.isAnySelected(category) : false; this.areAllChildrenSelected = category ? currentSelection.areAllSelected(category) : false; } + protected initialize(): void { + return; + } + protected handleCollectionState(): void { + // No need, as categoryId will be updated instead + return; + } } diff --git a/src/presentation/Scripts/Grouping/TheGrouper.vue b/src/presentation/Scripts/Grouping/TheGrouper.vue index 553680e5..e77e24dd 100644 --- a/src/presentation/Scripts/Grouping/TheGrouper.vue +++ b/src/presentation/Scripts/Grouping/TheGrouper.vue @@ -15,15 +15,13 @@ diff --git a/src/presentation/Scripts/TheScripts.vue b/src/presentation/Scripts/TheScripts.vue index 8795fc37..6c84c31b 100644 --- a/src/presentation/Scripts/TheScripts.vue +++ b/src/presentation/Scripts/TheScripts.vue @@ -1,10 +1,12 @@ + \ No newline at end of file diff --git a/src/presentation/StatefulVue.ts b/src/presentation/StatefulVue.ts index e7b0faac..36061b91 100644 --- a/src/presentation/StatefulVue.ts +++ b/src/presentation/StatefulVue.ts @@ -1,13 +1,40 @@ -import { Vue } from 'vue-property-decorator'; +import { Component, Vue } from 'vue-property-decorator'; import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy'; import { IApplicationContext } from '@/application/Context/IApplicationContext'; import { buildContext } from '@/application/Context/ApplicationContextProvider'; +import { IApplicationContextChangedEvent } from '../application/Context/IApplicationContext'; +import { IApplication } from '@/domain/IApplication'; +import { ICategoryCollectionState } from '../application/Context/State/ICategoryCollectionState'; +import { IEventSubscription } from '../infrastructure/Events/ISubscription'; +// @ts-ignore because https://github.com/vuejs/vue-class-component/issues/91 +@Component export abstract class StatefulVue extends Vue { - private static instance = new AsyncLazy( + public static instance = new AsyncLazy( () => Promise.resolve(buildContext())); + private listener: IEventSubscription; + + public async mounted() { + const context = await this.getCurrentContextAsync(); + this.listener = context.contextChanged.on((event) => this.handleStateChangedEvent(event)); + this.initialize(context.app); + this.handleCollectionState(context.state, undefined); + } + public destroyed() { + if (this.listener) { + this.listener.unsubscribe(); + } + } + + protected abstract initialize(app: IApplication): void; + protected abstract handleCollectionState( + newState: ICategoryCollectionState, oldState: ICategoryCollectionState | undefined): void; protected getCurrentContextAsync(): Promise { return StatefulVue.instance.getValueAsync(); } + + private handleStateChangedEvent(event: IApplicationContextChangedEvent) { + this.handleCollectionState(event.newState, event.oldState); + } } diff --git a/src/presentation/TheCodeArea.vue b/src/presentation/TheCodeArea.vue index 26bd11f9..b4c54b35 100644 --- a/src/presentation/TheCodeArea.vue +++ b/src/presentation/TheCodeArea.vue @@ -7,27 +7,14 @@ import { Component, Prop } from 'vue-property-decorator'; import { StatefulVue } from './StatefulVue'; import ace from 'ace-builds'; import 'ace-builds/webpack-resolver'; -import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder'; import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent'; import { IScript } from '@/domain/IScript'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; - -const NothingChosenCode = - new CodeBuilder() - .appendCommentLine('privacy.sexy — 🔐 Enforce privacy & security best-practices on Windows') - .appendLine() - .appendCommentLine('-- 🤔 How to use') - .appendCommentLine(' 📙 Start by exploring different categories and choosing different tweaks.') - .appendCommentLine(' 📙 On top left, you can apply predefined selections for privacy level you\'d like.') - .appendCommentLine(' 📙 After you choose any tweak, you can download or copy to execute your script.') - .appendCommentLine(' 📙 Come back regularly to apply latest version for stronger privacy and security.') - .appendLine() - .appendCommentLine('-- 🧐 Why privacy.sexy') - .appendCommentLine(' ✔️ Rich tweak pool to harden security & privacy of the OS and other software on it.') - .appendCommentLine(' ✔️ No need to run any compiled software on your system, just run the generated scripts.') - .appendCommentLine(' ✔️ Have full visibility into what the tweaks do as you enable them.') - .appendCommentLine(' ✔️ Open-source and free (both free as in beer and free as in speech).') - .toString(); +import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; +import { IApplication } from '@/domain/IApplication'; +import { IEventSubscription } from '@/infrastructure/Events/ISubscription'; +import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode'; +import { CodeBuilderFactory } from '@/application/Context/State/Code/Generation/CodeBuilderFactory'; @Component export default class TheCodeArea extends StatefulVue { @@ -35,21 +22,41 @@ export default class TheCodeArea extends StatefulVue { private editor!: ace.Ace.Editor; private currentMarkerId?: number; + private codeListener: IEventSubscription; @Prop() private theme!: string; - public async mounted() { - const context = await this.getCurrentContextAsync(); - this.editor = initializeEditor(this.theme, this.editorId, context.collection.scripting.language); - const appCode = context.state.code; - this.editor.setValue(appCode.current || NothingChosenCode, 1); - appCode.changed.on((code) => this.updateCode(code)); + public destroyed() { + this.unsubscribeCodeListening(); + this.destroyEditor(); } - private updateCode(event: ICodeChangedEvent) { + protected initialize(app: IApplication): void { + return; + } + protected handleCollectionState(newState: ICategoryCollectionState): void { + this.destroyEditor(); + this.editor = initializeEditor(this.theme, this.editorId, newState.collection.scripting.language); + const appCode = newState.code; + this.editor.setValue(appCode.current || getDefaultCode(newState.collection.scripting.language), 1); + this.unsubscribeCodeListening(); + this.subscribe(appCode); + } + + private subscribe(appCode: IApplicationCode) { + this.codeListener = appCode.changed.on((code) => this.updateCodeAsync(code)); + } + private unsubscribeCodeListening() { + if (this.codeListener) { + this.codeListener.unsubscribe(); + } + } + private async updateCodeAsync(event: ICodeChangedEvent) { this.removeCurrentHighlighting(); if (event.isEmpty()) { - this.editor.setValue(NothingChosenCode, 1); + const context = await this.getCurrentContextAsync(); + const defaultCode = getDefaultCode(context.state.collection.scripting.language); + this.editor.setValue(defaultCode, 1); return; } this.editor.setValue(event.code, 1); @@ -60,7 +67,6 @@ export default class TheCodeArea extends StatefulVue { this.reactToChanges(event, event.changedScripts); } } - private reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray) { const positions = scripts .map((script) => event.getScriptPositionInCode(script)); @@ -73,19 +79,16 @@ export default class TheCodeArea extends StatefulVue { this.scrollToLine(end + 2); this.highlight(start, end); } - private highlight(startRow: number, endRow: number) { const AceRange = ace.require('ace/range').Range; this.currentMarkerId = this.editor.session.addMarker( new AceRange(startRow, 0, endRow, 0), 'code-area__highlight', 'fullLine', ); } - private scrollToLine(row: number) { const column = this.editor.session.getLine(row).length; this.editor.gotoLine(row, column, true); } - private removeCurrentHighlighting() { if (!this.currentMarkerId) { return; @@ -93,6 +96,11 @@ export default class TheCodeArea extends StatefulVue { this.editor.session.removeMarker(this.currentMarkerId); this.currentMarkerId = undefined; } + private destroyEditor() { + if (this.editor) { + this.editor.destroy(); + } + } } function initializeEditor(theme: string, editorId: string, language: ScriptingLanguage): ace.Ace.Editor { @@ -109,13 +117,32 @@ function initializeEditor(theme: string, editorId: string, language: ScriptingLa function getLanguage(language: ScriptingLanguage) { switch (language) { - case ScriptingLanguage.batchfile: - return 'batchfile'; + case ScriptingLanguage.batchfile: return 'batchfile'; + case ScriptingLanguage.shellscript: return 'sh'; default: - throw new Error('unkown language'); + throw new Error('unknown language'); } } +function getDefaultCode(language: ScriptingLanguage): string { + return new CodeBuilderFactory() + .create(language) + .appendCommentLine('privacy.sexy — 🔐 Enforce privacy & security best-practices on Windows and macOS') + .appendLine() + .appendCommentLine('-- 🤔 How to use') + .appendCommentLine(' 📙 Start by exploring different categories and choosing different tweaks.') + .appendCommentLine(' 📙 On top left, you can apply predefined selections for privacy level you\'d like.') + .appendCommentLine(' 📙 After you choose any tweak, you can download or copy to execute your script.') + .appendCommentLine(' 📙 Come back regularly to apply latest version for stronger privacy and security.') + .appendLine() + .appendCommentLine('-- 🧐 Why privacy.sexy') + .appendCommentLine(' ✔️ Rich tweak pool to harden security & privacy of the OS and other software on it.') + .appendCommentLine(' ✔️ No need to run any compiled software on your system, just run the generated scripts.') + .appendCommentLine(' ✔️ Have full visibility into what the tweaks do as you enable them.') + .appendCommentLine(' ✔️ Open-source and free (both free as in beer and free as in speech).') + .toString(); +} + diff --git a/src/presentation/TheFooter/DownloadUrlListItem.vue b/src/presentation/TheFooter/DownloadUrlListItem.vue index dc876d2c..646fe04e 100644 --- a/src/presentation/TheFooter/DownloadUrlListItem.vue +++ b/src/presentation/TheFooter/DownloadUrlListItem.vue @@ -13,11 +13,12 @@ import { Component, Prop, Watch } from 'vue-property-decorator'; import { StatefulVue } from '@/presentation/StatefulVue'; import { Environment } from '@/application/Environment/Environment'; import { OperatingSystem } from '@/domain/OperatingSystem'; +import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; +import { IApplication } from '@/domain/IApplication'; @Component export default class DownloadUrlListItem extends StatefulVue { @Prop() public operatingSystem!: OperatingSystem; - public OperatingSystem = OperatingSystem; public downloadUrl: string = ''; public operatingSystemName: string = ''; @@ -37,6 +38,13 @@ export default class DownloadUrlListItem extends StatefulVue { this.hasCurrentOsDesktopVersion = hasDesktopVersion(currentOs); } + protected initialize(app: IApplication): void { + return; + } + protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void { + return; + } + private async getDownloadUrlAsync(os: OperatingSystem): Promise { const context = await this.getCurrentContextAsync(); return context.app.info.getDownloadUrl(os); diff --git a/src/presentation/TheFooter/PrivacyPolicy.vue b/src/presentation/TheFooter/PrivacyPolicy.vue index 087887c2..dbc17039 100644 --- a/src/presentation/TheFooter/PrivacyPolicy.vue +++ b/src/presentation/TheFooter/PrivacyPolicy.vue @@ -34,24 +34,22 @@ import { Component } from 'vue-property-decorator'; import { StatefulVue } from '@/presentation/StatefulVue'; import { Environment } from '@/application/Environment/Environment'; +import { IApplication } from '@/domain/IApplication'; @Component -export default class TheFooter extends StatefulVue { +export default class PrivacyPolicy extends StatefulVue { public repositoryUrl: string = ''; public feedbackUrl: string = ''; - public isDesktop: boolean = false; + public isDesktop = Environment.CurrentEnvironment.isDesktop; - constructor() { - super(); - this.isDesktop = Environment.CurrentEnvironment.isDesktop; - } - - public async mounted() { - const context = await this.getCurrentContextAsync(); - const info = context.app.info; + protected initialize(app: IApplication): void { + const info = app.info; this.repositoryUrl = info.repositoryWebUrl; this.feedbackUrl = info.feedbackUrl; } + protected handleCollectionState(): void { + return; + } } diff --git a/src/presentation/TheFooter/TheFooter.vue b/src/presentation/TheFooter/TheFooter.vue index 0b7e3581..b540e38e 100644 --- a/src/presentation/TheFooter/TheFooter.vue +++ b/src/presentation/TheFooter/TheFooter.vue @@ -52,6 +52,8 @@ import { StatefulVue } from '@/presentation/StatefulVue'; import { Environment } from '@/application/Environment/Environment'; import PrivacyPolicy from './PrivacyPolicy.vue'; import DownloadUrlList from './DownloadUrlList.vue'; +import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; +import { IApplication } from '@/domain/IApplication'; @Component({ components: { @@ -73,15 +75,18 @@ export default class TheFooter extends StatefulVue { this.isDesktop = Environment.CurrentEnvironment.isDesktop; } - public async mounted() { - const context = await this.getCurrentContextAsync(); - const info = context.app.info; + protected initialize(app: IApplication): void { + const info = app.info; this.version = info.version; this.homepageUrl = info.homepage; this.repositoryUrl = info.repositoryWebUrl; this.releaseUrl = info.releaseUrl; this.feedbackUrl = info.feedbackUrl; } + + protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void { + return; + } } diff --git a/src/presentation/TheHeader.vue b/src/presentation/TheHeader.vue index 5c3be536..3dc0a1f1 100644 --- a/src/presentation/TheHeader.vue +++ b/src/presentation/TheHeader.vue @@ -1,11 +1,13 @@ diff --git a/src/presentation/TheSearchBar.vue b/src/presentation/TheSearchBar.vue index 4afd198a..e553669f 100644 --- a/src/presentation/TheSearchBar.vue +++ b/src/presentation/TheSearchBar.vue @@ -13,7 +13,11 @@ import { Component, Watch } from 'vue-property-decorator'; import { StatefulVue } from './StatefulVue'; import { NonCollapsing } from '@/presentation/Scripts/Cards/NonCollapsingDirective'; -import { IUserFilter } from '@/application/Context/State/ICategoryCollectionState'; +import { IUserFilter } from '@/application/Context/State/Filter/IUserFilter'; +import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; +import { IApplication } from '@/domain/IApplication'; +import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; +import { IEventSubscription } from '@/infrastructure/Events/ISubscription'; @Component( { directives: { NonCollapsing }, @@ -23,12 +27,7 @@ export default class TheSearchBar extends StatefulVue { public searchPlaceHolder = 'Search'; public searchQuery = ''; - public async mounted() { - const context = await this.getCurrentContextAsync(); - const totalScripts = context.collection.totalScripts; - this.searchPlaceHolder = `Search in ${totalScripts} scripts`; - this.beginReacting(context.state.filter); - } + private readonly listeners = new Array(); @Watch('searchQuery') public async updateFilterAsync(newFilter: |string) { @@ -40,10 +39,34 @@ export default class TheSearchBar extends StatefulVue { filter.setFilter(newFilter); } } + public destroyed() { + this.unsubscribeAll(); + } - private beginReacting(filter: IUserFilter) { - filter.filtered.on((result) => this.searchQuery = result.query); - filter.filterRemoved.on(() => this.searchQuery = ''); + protected initialize(app: IApplication): void { + return; + } + protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState | undefined) { + const totalScripts = newState.collection.totalScripts; + this.searchPlaceHolder = `Search in ${totalScripts} scripts`; + this.searchQuery = newState.filter.currentFilter ? newState.filter.currentFilter.query : ''; + this.unsubscribeAll(); + this.subscribe(newState.filter); + } + + private subscribe(filter: IUserFilter) { + this.listeners.push(filter.filtered.on((result) => this.handleFiltered(result))); + this.listeners.push(filter.filterRemoved.on(() => this.handleFilterRemoved())); + } + private unsubscribeAll() { + this.listeners.forEach((listener) => listener.unsubscribe()); + this.listeners.splice(0, this.listeners.length); + } + private handleFiltered(result: IFilterResult) { + this.searchQuery = result.query; + } + private handleFilterRemoved() { + this.searchQuery = ''; } } diff --git a/tests/unit/application/Context/ApplicationContext.spec.ts b/tests/unit/application/Context/ApplicationContext.spec.ts index 8934b9f0..bf873b19 100644 --- a/tests/unit/application/Context/ApplicationContext.spec.ts +++ b/tests/unit/application/Context/ApplicationContext.spec.ts @@ -22,7 +22,7 @@ describe('ApplicationContext', () => { .construct(); sut.changeContext(OperatingSystem.macOS); // assert - expect(sut.collection).to.equal(expectedCollection); + expect(sut.state.collection).to.equal(expectedCollection); }); it('currentOs is changed as expected', () => { // arrange @@ -35,9 +35,9 @@ describe('ApplicationContext', () => { .construct(); sut.changeContext(expectedOs); // assert - expect(sut.currentOs).to.equal(expectedOs); + expect(sut.state.os).to.equal(expectedOs); }); - it('state is changed as expected', () => { + it('new state is empty', () => { // arrange const testContext = new ObservableApplicationContextFactory() .withAppContainingCollections(OperatingSystem.Windows, OperatingSystem.macOS); @@ -45,6 +45,7 @@ describe('ApplicationContext', () => { const sut = testContext .withInitialOs(OperatingSystem.Windows) .construct(); + sut.state.filter.setFilter('filtered'); sut.changeContext(OperatingSystem.macOS); // assert expectEmptyState(sut.state); @@ -82,12 +83,13 @@ describe('ApplicationContext', () => { const sut = testContext .withInitialOs(OperatingSystem.Windows) .construct(); + const oldState = sut.state; sut.changeContext(nextOs); // assert expect(testContext.firedEvents.length).to.equal(1); - expect(testContext.firedEvents[0].newCollection).to.equal(expectedCollection); expect(testContext.firedEvents[0].newState).to.equal(sut.state); - expect(testContext.firedEvents[0].newOs).to.equal(nextOs); + expect(testContext.firedEvents[0].newState.collection).to.equal(expectedCollection); + expect(testContext.firedEvents[0].oldState).to.equal(oldState); }); it('is not fired when initial os is changed to same one', () => { // arrange @@ -148,7 +150,7 @@ describe('ApplicationContext', () => { .withInitialOs(os) .construct(); // assert - const actual = sut.collection; + const actual = sut.state.collection; expect(actual).to.deep.equal(expected); }); }); @@ -174,7 +176,7 @@ describe('ApplicationContext', () => { .withInitialOs(expected) .construct(); // assert - const actual = sut.currentOs; + const actual = sut.state.os; expect(actual).to.deep.equal(expected); }); describe('throws when OS is invalid', () => { diff --git a/tests/unit/application/Context/ApplicationContextProvider.spec.ts b/tests/unit/application/Context/ApplicationContextProvider.spec.ts index 894a66e6..1b9fddfa 100644 --- a/tests/unit/application/Context/ApplicationContextProvider.spec.ts +++ b/tests/unit/application/Context/ApplicationContextProvider.spec.ts @@ -17,7 +17,7 @@ describe('ApplicationContextProvider', () => { // act const context = buildContext(parserMock); // assert - // TODO: expect(expected).to.equal(context.app); + expect(expected).to.equal(context.app); }); describe('sets initial OS as expected', () => { it('returns currentOs if it is supported', () => { @@ -28,7 +28,8 @@ describe('ApplicationContextProvider', () => { // act const context = buildContext(parser, environment); // assert - expect(expected).to.equal(context.currentOs); + const actual = context.state.os; + expect(expected).to.equal(actual); }); it('fallbacks to other os if OS in environment is not supported', () => { // arrange @@ -39,11 +40,25 @@ describe('ApplicationContextProvider', () => { // act const context = buildContext(parser, environment); // assert - const actual = context.currentOs; + const actual = context.state.os; expect(expected).to.equal(actual); }); it('fallbacks to most supported os if current os is not supported', () => { - // TODO: After more than single collection can be parsed + // arrange + const expectedOs = OperatingSystem.Android; + const allCollections = [ + new CategoryCollectionStub().withOs(OperatingSystem.Linux).withTotalScripts(3), + new CategoryCollectionStub().withOs(expectedOs).withTotalScripts(5), + new CategoryCollectionStub().withOs(OperatingSystem.Windows).withTotalScripts(4), + ]; + const environment = new EnvironmentStub().withOs(OperatingSystem.macOS); + const app = new ApplicationStub().withCollections(...allCollections); + const parser: ApplicationParserType = () => app; + // act + const context = buildContext(parser, environment); + // assert + const actual = context.state.os; + expect(expectedOs).to.equal(actual, `Expected: ${OperatingSystem[expectedOs]}, actual: ${OperatingSystem[actual]}`); }); }); }); diff --git a/tests/unit/application/Context/State/ApplicationState.spec.ts b/tests/unit/application/Context/State/CategoryCollectionState.spec.ts similarity index 82% rename from tests/unit/application/Context/State/ApplicationState.spec.ts rename to tests/unit/application/Context/State/CategoryCollectionState.spec.ts index fc6cec40..2c35b59b 100644 --- a/tests/unit/application/Context/State/ApplicationState.spec.ts +++ b/tests/unit/application/Context/State/CategoryCollectionState.spec.ts @@ -3,6 +3,7 @@ import { expect } from 'chai'; import { UserSelection } from '@/application/Context/State/Selection/UserSelection'; import { ApplicationCode } from '@/application/Context/State/Code/ApplicationCode'; import { CategoryCollectionState } from '@/application/Context/State/CategoryCollectionState'; +import { OperatingSystem } from '@/domain/OperatingSystem'; import { IScript } from '@/domain/IScript'; import { ScriptStub } from '../../../stubs/ScriptStub'; import { CategoryStub } from '../../../stubs/CategoryStub'; @@ -21,7 +22,8 @@ describe('CategoryCollectionState', () => { }); it('reacts to selection changes as expected', () => { // arrange - const collection = new CategoryCollectionStub().withAction(new CategoryStub(0).withScriptIds('scriptId')); + const collection = new CategoryCollectionStub() + .withAction(new CategoryStub(0).withScriptIds('scriptId')); const selectionStub = new UserSelection(collection, []); const expectedCodeGenerator = new ApplicationCode(selectionStub, collection.scripting); selectionStub.selectAll(); @@ -34,6 +36,19 @@ describe('CategoryCollectionState', () => { expect(actualCode).to.equal(expectedCode); }); }); + describe('os', () => { + it('same as its collection', () => { + // arrange + const expected = OperatingSystem.macOS; + const collection = new CategoryCollectionStub() + .withOs(expected); + // act + const sut = new CategoryCollectionState(collection); + // assert + const actual = sut.os; + expect(expected).to.equal(actual); + }); + }); describe('selection', () => { it('initialized with no selection', () => { // arrange @@ -70,7 +85,8 @@ describe('CategoryCollectionState', () => { it('can match a script from current collection', () => { // arrange const scriptNameFilter = 'scriptName'; - const expectedScript = new ScriptStub('scriptId').withName(scriptNameFilter); + const expectedScript = new ScriptStub('scriptId') + .withName(scriptNameFilter); const collection = new CategoryCollectionStub() .withAction(new CategoryStub(0).withScript(expectedScript)); const sut = new CategoryCollectionState(collection); diff --git a/tests/unit/application/Context/State/Code/Generation/CodeBuilder.spec.ts b/tests/unit/application/Context/State/Code/Generation/CodeBuilder.spec.ts index 5fbd20b3..b8acda15 100644 --- a/tests/unit/application/Context/State/Code/Generation/CodeBuilder.spec.ts +++ b/tests/unit/application/Context/State/Code/Generation/CodeBuilder.spec.ts @@ -3,10 +3,23 @@ import { expect } from 'chai'; import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder'; describe('CodeBuilder', () => { + class CodeBuilderConcrete extends CodeBuilder { + private commentDelimiter = '//'; + public withCommentDelimiter(delimiter: string): CodeBuilderConcrete { + this.commentDelimiter = delimiter; + return this; + } + protected getCommentDelimiter(): string { + return this.commentDelimiter; + } + protected writeStandardOut(text: string): string { + return text; + } + } describe('appendLine', () => { it('when empty appends empty line', () => { // arrange - const sut = new CodeBuilder(); + const sut = new CodeBuilderConcrete(); // act sut.appendLine().appendLine().appendLine(); // assert @@ -14,7 +27,7 @@ describe('CodeBuilder', () => { }); it('when not empty append string in new line', () => { // arrange - const sut = new CodeBuilder(); + const sut = new CodeBuilderConcrete(); const expected = 'str'; // act sut.appendLine() @@ -27,7 +40,7 @@ describe('CodeBuilder', () => { }); it('appendFunction', () => { // arrange - const sut = new CodeBuilder(); + const sut = new CodeBuilderConcrete(); const functionName = 'function'; const code = 'code'; // act @@ -39,11 +52,13 @@ describe('CodeBuilder', () => { }); it('appendTrailingHyphensCommentLine', () => { // arrange - const sut = new CodeBuilder(); - const totalHypens = 5; - const expected = `:: ${'-'.repeat(totalHypens)}`; + const commentDelimiter = '//'; + const sut = new CodeBuilderConcrete() + .withCommentDelimiter(commentDelimiter); + const totalHyphens = 5; + const expected = `${commentDelimiter} ${'-'.repeat(totalHyphens)}`; // act - sut.appendTrailingHyphensCommentLine(totalHypens); + sut.appendTrailingHyphensCommentLine(totalHyphens); // assert const result = sut.toString(); const lines = getLines(result); @@ -51,38 +66,45 @@ describe('CodeBuilder', () => { }); it('appendCommentLine', () => { // arrange - const sut = new CodeBuilder(); + const commentDelimiter = '//'; + const sut = new CodeBuilderConcrete() + .withCommentDelimiter(commentDelimiter); const comment = 'comment'; - const expected = ':: comment'; + const expected = `${commentDelimiter} comment`; // act - sut.appendCommentLine(comment); + const result = sut + .appendCommentLine(comment) + .toString(); // assert - const result = sut.toString(); const lines = getLines(result); expect(lines[0]).to.equal(expected); }); it('appendCommentLineWithHyphensAround', () => { // arrange - const sut = new CodeBuilder(); + const commentDelimiter = '//'; + const sut = new CodeBuilderConcrete() + .withCommentDelimiter(commentDelimiter); const sectionName = 'section'; - const totalHypens = sectionName.length + 3 * 2; - const expected = ':: ---section---'; - sut.appendCommentLineWithHyphensAround(sectionName, totalHypens); + const totalHyphens = sectionName.length + 3 * 2; + const expected = `${commentDelimiter} ---section---`; + // act + const result = sut + .appendCommentLineWithHyphensAround(sectionName, totalHyphens) + .toString(); // assert - const result = sut.toString(); const lines = getLines(result); expect(lines[1]).to.equal(expected); }); describe('currentLine', () => { it('no lines returns zero', () => { // arrange & act - const sut = new CodeBuilder(); + const sut = new CodeBuilderConcrete(); // assert expect(sut.currentLine).to.equal(0); }); it('single line returns one', () => { // arrange - const sut = new CodeBuilder(); + const sut = new CodeBuilderConcrete(); // act sut.appendLine(); // assert @@ -90,15 +112,17 @@ describe('CodeBuilder', () => { }); it('multiple lines returns as expected', () => { // arrange - const sut = new CodeBuilder(); + const sut = new CodeBuilderConcrete(); // act - sut.appendLine('1').appendCommentLine('2').appendLine(); + sut.appendLine('1') + .appendCommentLine('2') + .appendLine(); // assert expect(sut.currentLine).to.equal(3); }); it('multiple lines in code', () => { // arrange - const sut = new CodeBuilder(); + const sut = new CodeBuilderConcrete(); // act sut.appendLine('hello\ncode-here\nwith-3-lines'); // assert diff --git a/tests/unit/application/Context/State/Code/Generation/CodeBuilderFactory.spec.ts b/tests/unit/application/Context/State/Code/Generation/CodeBuilderFactory.spec.ts new file mode 100644 index 00000000..cd0b0a69 --- /dev/null +++ b/tests/unit/application/Context/State/Code/Generation/CodeBuilderFactory.spec.ts @@ -0,0 +1,36 @@ +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'; + +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]}"`); + }); + }); +}); diff --git a/tests/unit/application/Context/State/Code/Generation/Languages/BatchBuilder.spec.ts b/tests/unit/application/Context/State/Code/Generation/Languages/BatchBuilder.spec.ts new file mode 100644 index 00000000..394a42d7 --- /dev/null +++ b/tests/unit/application/Context/State/Code/Generation/Languages/BatchBuilder.spec.ts @@ -0,0 +1,37 @@ +import 'mocha'; +import { expect } from 'chai'; +import { BatchBuilder } from '@/application/Context/State/Code/Generation/Languages/BatchBuilder'; + +describe('BatchBuilder', () => { + class BatchBuilderRevealer extends BatchBuilder { + public getCommentDelimiter(): string { + return super.getCommentDelimiter(); + } + public writeStandardOut(text: string): string { + return super.writeStandardOut(text); + } + } + describe('getCommentDelimiter', () => { + it('returns expected', () => { + // arrange + const expected = '::'; + const sut = new BatchBuilderRevealer(); + // act + const actual = sut.getCommentDelimiter(); + // assert + expect(expected).to.equal(actual); + }); + }); + describe('writeStandardOut', () => { + it('prepends expected', () => { + // arrange + const text = 'test'; + const expected = `echo ${text}`; + const sut = new BatchBuilderRevealer(); + // act + const actual = sut.writeStandardOut(text); + // assert + expect(expected).to.equal(actual); + }); + }); +}); diff --git a/tests/unit/application/Context/State/Code/Generation/Languages/ShellBuilder.spec.ts b/tests/unit/application/Context/State/Code/Generation/Languages/ShellBuilder.spec.ts new file mode 100644 index 00000000..18929a9e --- /dev/null +++ b/tests/unit/application/Context/State/Code/Generation/Languages/ShellBuilder.spec.ts @@ -0,0 +1,37 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ShellBuilder } from '@/application/Context/State/Code/Generation/Languages/ShellBuilder'; + +describe('ShellBuilder', () => { + class ShellBuilderRevealer extends ShellBuilder { + public getCommentDelimiter(): string { + return super.getCommentDelimiter(); + } + public writeStandardOut(text: string): string { + return super.writeStandardOut(text); + } + } + describe('getCommentDelimiter', () => { + it('returns expected', () => { + // arrange + const expected = '#'; + const sut = new ShellBuilderRevealer(); + // act + const actual = sut.getCommentDelimiter(); + // assert + expect(expected).to.equal(actual); + }); + }); + describe('writeStandardOut', () => { + it('prepends expected', () => { + // arrange + const text = 'test'; + const expected = `echo '${text}'`; + const sut = new ShellBuilderRevealer(); + // act + const actual = sut.writeStandardOut(text); + // assert + expect(expected).to.equal(actual); + }); + }); +}); diff --git a/tests/unit/application/Context/State/Code/Generation/UserScriptGenerator.spec.ts b/tests/unit/application/Context/State/Code/Generation/UserScriptGenerator.spec.ts index dd2fdd54..25032a66 100644 --- a/tests/unit/application/Context/State/Code/Generation/UserScriptGenerator.spec.ts +++ b/tests/unit/application/Context/State/Code/Generation/UserScriptGenerator.spec.ts @@ -2,7 +2,8 @@ import 'mocha'; import { expect } from 'chai'; import { UserScriptGenerator } from '@/application/Context/State/Code/Generation/UserScriptGenerator'; import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; -import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder'; +import { ICodeBuilderFactory } from '@/application/Context/State/Code/Generation/ICodeBuilderFactory'; +import { ICodeBuilder } from '@/application/Context/State/Code/Generation/ICodeBuilder'; import { ScriptStub } from '../../../../../stubs/ScriptStub'; import { ScriptingDefinitionStub } from '../../../../../stubs/ScriptingDefinitionStub'; @@ -28,14 +29,15 @@ describe('UserScriptGenerator', () => { }); it('is not prepended if empty', () => { // arrange - const sut = new UserScriptGenerator(); + const codeBuilderStub = new CodeBuilderStub(); + const sut = new UserScriptGenerator(mockCodeBuilderFactory(codeBuilderStub)); const script = new ScriptStub('id') .withCode('code\nmulti-lined') .toSelectedScript(); const definition = new ScriptingDefinitionStub() .withStartCode(undefined) .withEndCode(undefined); - const expectedStart = new CodeBuilder() + const expectedStart = codeBuilderStub .appendFunction(script.script.name, script.script.code.execute) .toString(); // act @@ -64,15 +66,16 @@ describe('UserScriptGenerator', () => { }); it('is not appended if empty', () => { // arrange - const sut = new UserScriptGenerator(); + const codeBuilderStub = new CodeBuilderStub(); + const sut = new UserScriptGenerator(mockCodeBuilderFactory(codeBuilderStub)); const script = new ScriptStub('id') .withCode('code\nmulti-lined') .toSelectedScript(); - const definition = new ScriptingDefinitionStub() - .withEndCode(undefined); - const expectedEnd = new CodeBuilder() + const expectedEnd = codeBuilderStub .appendFunction(script.script.name, script.script.code.execute) .toString(); + const definition = new ScriptingDefinitionStub() + .withEndCode(undefined); // act const code = sut.buildCode([script], definition); // assert @@ -199,3 +202,36 @@ describe('UserScriptGenerator', () => { }); }); }); + +function mockCodeBuilderFactory(mock: ICodeBuilder): ICodeBuilderFactory { + return { + create: () => mock, + }; +} + +class CodeBuilderStub implements ICodeBuilder { + public currentLine = 0; + private text = ''; + public appendLine(code?: string): ICodeBuilder { + this.text += this.text ? `${code}\n` : code; + this.currentLine++; + return this; + } + public appendTrailingHyphensCommentLine(totalRepeatHyphens: number): ICodeBuilder { + return this.appendLine(`trailing-hyphens-${totalRepeatHyphens}`); + } + public appendCommentLine(commentLine?: string): ICodeBuilder { + return this.appendLine(`Comment | ${commentLine}`); + } + public appendCommentLineWithHyphensAround(sectionName: string, totalRepeatHyphens: number): ICodeBuilder { + return this.appendLine(`hyphens-around-${totalRepeatHyphens} | Section name: ${sectionName} | hyphens-around-${totalRepeatHyphens}`); + } + public appendFunction(name: string, code: string): ICodeBuilder { + return this + .appendLine(`Function | Name: ${name}`) + .appendLine(`Function | Code: ${code}`); + } + public toString(): string { + return this.text; + } +} diff --git a/tests/unit/application/Parser/ApplicationParser.spec.ts b/tests/unit/application/Parser/ApplicationParser.spec.ts index 69a486d8..2eecc311 100644 --- a/tests/unit/application/Parser/ApplicationParser.spec.ts +++ b/tests/unit/application/Parser/ApplicationParser.spec.ts @@ -3,11 +3,13 @@ import { expect } from 'chai'; import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser'; import { CategoryCollectionParserType, parseApplication } from '@/application/Parser/ApplicationParser'; import WindowsData from 'js-yaml-loader!@/application/collections/windows.yaml'; +import MacOsData from 'js-yaml-loader!@/application/collections/macos.yaml'; import { CollectionData } from 'js-yaml-loader!@/*'; import { IProjectInformation } from '@/domain/IProjectInformation'; import { ProjectInformation } from '@/domain/ProjectInformation'; import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { OperatingSystem } from '@/domain/OperatingSystem'; +import { getEnumValues } from '@/application/Common/Enum'; import { CategoryCollectionStub } from '../../stubs/CategoryCollectionStub'; import { getProcessEnvironmentStub } from '../../stubs/ProcessEnvironmentStub'; import { CollectionDataStub } from '../../stubs/CollectionDataStub'; @@ -24,15 +26,18 @@ describe('ApplicationParser', () => { it('returns result from the parser', () => { // arrange const os = OperatingSystem.macOS; + const data = new CollectionDataStub(); const expected = new CategoryCollectionStub() .withOs(os); const parser = new CategoryCollectionParserSpy() - .setResult(expected) + .setUpReturnValue(data, expected) .mockParser(); + const env = getProcessEnvironmentStub(); + const collections = [ data ]; // act - const context = parseApplication(parser); + const app = parseApplication(parser, env, collections); // assert - const actual = context.getCollection(os); + const actual = app.getCollection(os); expect(expected).to.equal(actual); }); }); @@ -44,10 +49,10 @@ describe('ApplicationParser', () => { const parserSpy = new CategoryCollectionParserSpy(); const parserMock = parserSpy.mockParser(); // act - const context = parseApplication(parserMock, env); + const app = parseApplication(parserMock, env); // assert - expect(expected).to.deep.equal(context.info); - expect(expected).to.deep.equal(parserSpy.lastArguments.info); + expect(expected).to.deep.equal(app.info); + expect(parserSpy.arguments.map((arg) => arg.info).every((info) => info === expected)); }); it('defaults to process.env', () => { // arrange @@ -56,54 +61,110 @@ describe('ApplicationParser', () => { const parserSpy = new CategoryCollectionParserSpy(); const parserMock = parserSpy.mockParser(); // act - const context = parseApplication(parserMock); + const app = parseApplication(parserMock); // assert - expect(expected).to.deep.equal(context.info); - expect(expected).to.deep.equal(parserSpy.lastArguments.info); + expect(expected).to.deep.equal(app.info); + expect(parserSpy.arguments.map((arg) => arg.info).every((info) => info === expected)); }); }); - describe('collectionData', () => { - it('parsed with expected data', () => { + describe('collectionsData', () => { + describe('set as expected', () => { // arrange - const expected = new CollectionDataStub(); - const env = getProcessEnvironmentStub(); - const parserSpy = new CategoryCollectionParserSpy(); - const parserMock = parserSpy.mockParser(); + const testCases = [ + { + name: 'single collection', + input: [ new CollectionDataStub() ], + output: [ new CategoryCollectionStub().withOs(OperatingSystem.macOS) ], + }, + { + name: 'multiple collections', + input: [ + new CollectionDataStub().withOs('windows'), + new CollectionDataStub().withOs('macos'), + ], + output: [ + new CategoryCollectionStub().withOs(OperatingSystem.macOS), + new CategoryCollectionStub().withOs(OperatingSystem.Windows), + ], + }, + ]; // act - parseApplication(parserMock, env, expected); - // assert - expect(expected).to.equal(parserSpy.lastArguments.file); + for (const testCase of testCases) { + it(testCase.name, () => { + const env = getProcessEnvironmentStub(); + let parserSpy = new CategoryCollectionParserSpy(); + for (let i = 0; i < testCase.input.length; i++) { + parserSpy = parserSpy.setUpReturnValue(testCase.input[i], testCase.output[i]); + } + const parserMock = parserSpy.mockParser(); + // act + const app = parseApplication(parserMock, env, testCase.input); + // assert + expect(app.collections).to.deep.equal(testCase.output); + }); + } }); - it('defaults to windows data', () => { + it('defaults to expected data', () => { // arrange - const expected = WindowsData; + const expected = [ WindowsData, MacOsData ]; const parserSpy = new CategoryCollectionParserSpy(); const parserMock = parserSpy.mockParser(); // act parseApplication(parserMock); // assert - expect(expected).to.equal(parserSpy.lastArguments.file); + const actual = parserSpy.arguments.map((args) => args.data); + expect(actual).to.deep.equal(expected); + }); + describe('throws when data is invalid', () => { + // arrange + const testCases = [ + { + expectedError: 'no collection provided', + data: [], + }, + { + expectedError: 'undefined collection provided', + data: [ new CollectionDataStub(), undefined ], + }, + ]; + for (const testCase of testCases) { + it(testCase.expectedError, () => { + const parserMock = new CategoryCollectionParserSpy().mockParser(); + const env = getProcessEnvironmentStub(); + // act + const act = () => parseApplication(parserMock, env, testCase.data); + // assert + expect(act).to.throw(testCase.expectedError); + }); + } }); }); }); }); class CategoryCollectionParserSpy { - public lastArguments: { - file: CollectionData; - info: ProjectInformation; - } = { file: undefined, info: undefined }; - private result: ICategoryCollection = new CategoryCollectionStub(); + public arguments = new Array<{ + data: CollectionData, + info: ProjectInformation, + }>(); - public setResult(collection: ICategoryCollection): CategoryCollectionParserSpy { - this.result = collection; + private returnValues = new Map(); + + public setUpReturnValue(data: CollectionData, collection: ICategoryCollection): CategoryCollectionParserSpy { + this.returnValues.set(data, collection); return this; } public mockParser(): CategoryCollectionParserType { - return (file: CollectionData, info: IProjectInformation) => { - this.lastArguments.file = file; - this.lastArguments.info = info; - return this.result; + return (data: CollectionData, info: IProjectInformation) => { + this.arguments.push({ data, info }); + if (this.returnValues.has(data)) { + return this.returnValues.get(data); + } else { + // Get next OS with a unique OS so mock does not result in invalid app (with duplicate OS collections) + const currentRun = this.arguments.length - 1; + const nextOs = getEnumValues(OperatingSystem)[currentRun]; + return new CategoryCollectionStub().withOs(nextOs); + } }; } } diff --git a/tests/unit/application/Parser/CategoryCollectionParser.spec.ts b/tests/unit/application/Parser/CategoryCollectionParser.spec.ts index a61df8ac..e2c8b555 100644 --- a/tests/unit/application/Parser/CategoryCollectionParser.spec.ts +++ b/tests/unit/application/Parser/CategoryCollectionParser.spec.ts @@ -8,8 +8,8 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; import { parseScriptingDefinition } from '@/application/Parser/ScriptingDefinitionParser'; import { mockEnumParser } from '../../stubs/EnumParserStub'; import { ProjectInformationStub } from '../../stubs/ProjectInformationStub'; -import { ScriptCompilerStub } from '../../stubs/ScriptCompilerStub'; import { getCategoryStub, CollectionDataStub } from '../../stubs/CollectionDataStub'; +import { CategoryCollectionParseContextStub } from '../../stubs/CategoryCollectionParseContextStub'; describe('CategoryCollectionParser', () => { describe('parseCategoryCollection', () => { @@ -48,8 +48,8 @@ describe('CategoryCollectionParser', () => { it('parses actions', () => { // arrange const actions = [ getCategoryStub('test1'), getCategoryStub('test2') ]; - const compiler = new ScriptCompilerStub(); - const expected = [ parseCategory(actions[0], compiler), parseCategory(actions[1], compiler) ]; + const context = new CategoryCollectionParseContextStub(); + const expected = [ parseCategory(actions[0], context), parseCategory(actions[1], context) ]; const collection = new CollectionDataStub() .withActions(actions); const info = new ProjectInformationStub(); diff --git a/tests/unit/application/Parser/CategoryParser.spec.ts b/tests/unit/application/Parser/CategoryParser.spec.ts index 9e08ab9c..b60a336d 100644 --- a/tests/unit/application/Parser/CategoryParser.spec.ts +++ b/tests/unit/application/Parser/CategoryParser.spec.ts @@ -2,10 +2,12 @@ import 'mocha'; import { expect } from 'chai'; import { parseCategory } from '@/application/Parser/CategoryParser'; import { CategoryData, CategoryOrScriptData } from 'js-yaml-loader!@/*'; -import { parseScript } from '@/application/Parser/ScriptParser'; +import { parseScript } from '@/application/Parser/Script/ScriptParser'; import { parseDocUrls } from '@/application/Parser/DocumentationParser'; import { ScriptCompilerStub } from '../../stubs/ScriptCompilerStub'; import { ScriptDataStub } from '../../stubs/ScriptDataStub'; +import { CategoryCollectionParseContextStub } from '../../stubs/CategoryCollectionParseContextStub'; +import { LanguageSyntaxStub } from '../../stubs/LanguageSyntaxStub'; describe('CategoryParser', () => { describe('parseCategory', () => { @@ -14,9 +16,9 @@ describe('CategoryParser', () => { // arrange const expectedMessage = 'category is null or undefined'; const category = undefined; - const compiler = new ScriptCompilerStub(); + const context = new CategoryCollectionParseContextStub(); // act - const act = () => parseCategory(category, compiler); + const act = () => parseCategory(category, context); // assert expect(act).to.throw(expectedMessage); }); @@ -28,9 +30,9 @@ describe('CategoryParser', () => { category: categoryName, children: [], }; - const compiler = new ScriptCompilerStub(); + const context = new CategoryCollectionParseContextStub(); // act - const act = () => parseCategory(category, compiler); + const act = () => parseCategory(category, context); // assert expect(act).to.throw(expectedMessage); }); @@ -42,9 +44,9 @@ describe('CategoryParser', () => { category: categoryName, children: undefined, }; - const compiler = new ScriptCompilerStub(); + const context = new CategoryCollectionParseContextStub(); // act - const act = () => parseCategory(category, compiler); + const act = () => parseCategory(category, context); // assert expect(act).to.throw(expectedMessage); }); @@ -57,21 +59,21 @@ describe('CategoryParser', () => { category: invalidName, children: getTestChildren(), }; - const compiler = new ScriptCompilerStub(); + const context = new CategoryCollectionParseContextStub(); // act - const act = () => parseCategory(category, compiler); + const act = () => parseCategory(category, context); // assert expect(act).to.throw(expectedMessage); }); }); }); - it('throws when compiler is undefined', () => { + it('throws when context is undefined', () => { // arrange - const expectedError = 'undefined compiler'; - const compiler = undefined; + const expectedError = 'undefined context'; + const context = undefined; const category = getValidCategory(); // act - const act = () => parseCategory(category, compiler); + const act = () => parseCategory(category, context); // assert expect(act).to.throw(expectedError); }); @@ -79,14 +81,14 @@ describe('CategoryParser', () => { // arrange const url = 'https://privacy.sexy'; const expected = parseDocUrls({ docs: url }); - const compiler = new ScriptCompilerStub(); const category: CategoryData = { category: 'category name', children: getTestChildren(), docs: url, }; + const context = new CategoryCollectionParseContextStub(); // act - const actual = parseCategory(category, compiler).documentationUrls; + const actual = parseCategory(category, context).documentationUrls; // assert expect(actual).to.deep.equal(expected); }); @@ -94,14 +96,14 @@ describe('CategoryParser', () => { it('single script with code', () => { // arrange const script = ScriptDataStub.createWithCode(); - const compiler = new ScriptCompilerStub(); - const expected = [ parseScript(script, compiler) ]; + const context = new CategoryCollectionParseContextStub(); + const expected = [ parseScript(script, context) ]; const category: CategoryData = { category: 'category name', children: [ script ], }; // act - const actual = parseCategory(category, compiler).scripts; + const actual = parseCategory(category, context).scripts; // assert expect(actual).to.deep.equal(expected); }); @@ -110,13 +112,15 @@ describe('CategoryParser', () => { const script = ScriptDataStub.createWithCall(); const compiler = new ScriptCompilerStub() .withCompileAbility(script); - const expected = [ parseScript(script, compiler) ]; + const context = new CategoryCollectionParseContextStub() + .withCompiler(compiler); + const expected = [ parseScript(script, context) ]; const category: CategoryData = { category: 'category name', children: [ script ], }; // act - const actual = parseCategory(category, compiler).scripts; + const actual = parseCategory(category, context).scripts; // assert expect(actual).to.deep.equal(expected); }); @@ -124,18 +128,44 @@ describe('CategoryParser', () => { // arrange const callableScript = ScriptDataStub.createWithCall(); const scripts = [ callableScript, ScriptDataStub.createWithCode() ]; - const compiler = new ScriptCompilerStub() - .withCompileAbility(callableScript); - const expected = scripts.map((script) => parseScript(script, compiler)); const category: CategoryData = { category: 'category name', children: scripts, }; + const compiler = new ScriptCompilerStub() + .withCompileAbility(callableScript); + const context = new CategoryCollectionParseContextStub() + .withCompiler(compiler); + const expected = scripts.map((script) => parseScript(script, context)); // act - const actual = parseCategory(category, compiler).scripts; + const actual = parseCategory(category, context).scripts; // assert expect(actual).to.deep.equal(expected); }); + it('script is created with right context', () => { // test through script validation logic + // arrange + const commentDelimiter = 'should not throw'; + const duplicatedCode = `${commentDelimiter} duplicate-line\n${commentDelimiter} duplicate-line`; + const parseContext = new CategoryCollectionParseContextStub() + .withSyntax(new LanguageSyntaxStub().withCommentDelimiters(commentDelimiter)); + const category: CategoryData = { + category: 'category name', + children: [ + { + category: 'sub-category', + children: [ + ScriptDataStub + .createWithoutCallOrCodes() + .withCode(duplicatedCode), + ], + }, + ], + }; + // act + const act = () => parseCategory(category, parseContext).scripts; + // assert + expect(act).to.not.throw(); + }); }); it('returns expected subcategories', () => { // arrange @@ -147,9 +177,9 @@ describe('CategoryParser', () => { category: 'category name', children: expected, }; - const compiler = new ScriptCompilerStub(); + const context = new CategoryCollectionParseContextStub(); // act - const actual = parseCategory(category, compiler).subCategories; + const actual = parseCategory(category, context).subCategories; // assert expect(actual).to.have.lengthOf(1); expect(actual[0].name).to.equal(expected[0].category); diff --git a/tests/unit/application/Parser/Compiler/ScriptCompiler.spec.ts b/tests/unit/application/Parser/Compiler/ScriptCompiler.spec.ts deleted file mode 100644 index 485044bc..00000000 --- a/tests/unit/application/Parser/Compiler/ScriptCompiler.spec.ts +++ /dev/null @@ -1,325 +0,0 @@ -import 'mocha'; -import { expect } from 'chai'; -import { ScriptCompiler } from '@/application/Parser/Compiler/ScriptCompiler'; -import { ScriptDataStub } from '../../../stubs/ScriptDataStub'; -import { FunctionData, ScriptData, FunctionCallData, ScriptFunctionCallData, FunctionCallParametersData } from 'js-yaml-loader!@/*'; -import { IScriptCode } from '@/domain/IScriptCode'; -import { IScriptCompiler } from '@/application/Parser/Compiler/IScriptCompiler'; - -describe('ScriptCompiler', () => { - describe('ctor', () => { - it('throws when functions have same names', () => { - // arrange - const expectedError = `duplicate function name: "same-func-name"`; - const functions: FunctionData[] = [ { - name: 'same-func-name', - code: 'non-empty-code', - }, { - name: 'same-func-name', - code: 'non-empty-code-2', - }]; - // act - const act = () => new ScriptCompiler(functions); - // assert - expect(act).to.throw(expectedError); - }); - it('throws when function parameters have same names', () => { - // arrange - const func: FunctionData = { - name: 'function-name', - code: 'non-empty-code', - parameters: [ 'duplicate', 'duplicate' ], - }; - const expectedError = `"${func.name}": duplicate parameter name: "duplicate"`; - // act - const act = () => new ScriptCompiler([func]); - // assert - expect(act).to.throw(expectedError); - }); - describe('throws when when function have duplicate code', () => { - it('code', () => { - // arrange - const expectedError = `duplicate "code" in functions: "duplicate-code"`; - const functions: FunctionData[] = [ { - name: 'func-1', - code: 'duplicate-code', - }, { - name: 'func-2', - code: 'duplicate-code', - }]; - // act - const act = () => new ScriptCompiler(functions); - // assert - expect(act).to.throw(expectedError); - }); - it('revertCode', () => { - // arrange - const expectedError = `duplicate "revertCode" in functions: "duplicate-revert-code"`; - const functions: FunctionData[] = [ { - name: 'func-1', - code: 'code-1', - revertCode: 'duplicate-revert-code', - }, { - name: 'func-2', - code: 'code-2', - revertCode: 'duplicate-revert-code', - }]; - // act - const act = () => new ScriptCompiler(functions); - // assert - expect(act).to.throw(expectedError); - }); - }); - }); - describe('canCompile', () => { - it('returns true if "call" is defined', () => { - // arrange - const sut = new ScriptCompiler([]); - const script = ScriptDataStub.createWithCall(); - // act - const actual = sut.canCompile(script); - // assert - expect(actual).to.equal(true); - }); - it('returns false if "call" is undefined', () => { - // arrange - const sut = new ScriptCompiler([]); - const script = ScriptDataStub.createWithCode(); - // act - const actual = sut.canCompile(script); - // assert - expect(actual).to.equal(false); - }); - }); - describe('compile', () => { - describe('invalid state', () => { - it('throws if functions are empty', () => { - // arrange - const expectedError = 'cannot compile without shared functions'; - const functions = []; - const sut = new ScriptCompiler(functions); - const script = ScriptDataStub.createWithCall(); - // act - const act = () => sut.compile(script); - // assert - expect(act).to.throw(expectedError); - }); - it('throws if call is not an object', () => { - // arrange - const expectedError = 'called function(s) must be an object'; - const invalidValues = [undefined, 'string', 33]; - const sut = new ScriptCompiler(createFunctions()); - invalidValues.forEach((invalidValue) => { - const script = ScriptDataStub.createWithoutCallOrCodes() // because call ctor overwrites "undefined" - .withCall(invalidValue as any); - // act - const act = () => sut.compile(script); - // assert - expect(act).to.throw(expectedError); - }); - }); - describe('invalid function reference', () => { - it('throws if function does not exist', () => { - // arrange - const sut = new ScriptCompiler(createFunctions()); - const nonExistingFunctionName = 'non-existing-func'; - const expectedError = `called function is not defined "${nonExistingFunctionName}"`; - const call: ScriptFunctionCallData = { function: nonExistingFunctionName }; - const script = ScriptDataStub.createWithCall(call); - // act - const act = () => sut.compile(script); - // assert - expect(act).to.throw(expectedError); - }); - it('throws if function is undefined', () => { - // arrange - const existingFunctionName = 'existing-func'; - const sut = new ScriptCompiler(createFunctions(existingFunctionName)); - const call: ScriptFunctionCallData = [ - { function: existingFunctionName }, - undefined, - ]; - const script = ScriptDataStub.createWithCall(call); - const expectedError = `undefined function call in script "${script.name}"`; - // act - const act = () => sut.compile(script); - // assert - expect(act).to.throw(expectedError); - }); - it('throws if function name is not given', () => { - // arrange - const existingFunctionName = 'existing-func'; - const sut = new ScriptCompiler(createFunctions(existingFunctionName)); - const call: FunctionCallData[] = [ - { function: existingFunctionName }, - { function: undefined }]; - const script = ScriptDataStub.createWithCall(call); - const expectedError = `empty function name called in script "${script.name}"`; - // act - const act = () => sut.compile(script); - // assert - expect(act).to.throw(expectedError); - }); - }); - }); - describe('builds code as expected', () => { - it('builds single call as expected', () => { - // arrange - const functionName = 'testSharedFunction'; - const expected: IScriptCode = { - execute: 'expected-code', - revert: 'expected-revert-code', - }; - const func: FunctionData = { - name: functionName, - parameters: [], - code: expected.execute, - revertCode: expected.revert, - }; - const sut = new ScriptCompiler([func]); - const call: FunctionCallData = { function: functionName }; - const script = ScriptDataStub.createWithCall(call); - // act - const actual = sut.compile(script); - // assert - expect(actual).to.deep.equal(expected); - }); - it('builds call sequence as expected', () => { - // arrange - const firstFunction: FunctionData = { - name: 'first-function-name', - parameters: [], - code: 'first-function-code', - revertCode: 'first-function-revert-code', - }; - const secondFunction: FunctionData = { - name: 'second-function-name', - parameters: [], - code: 'second-function-code', - revertCode: 'second-function-revert-code', - }; - const expected: IScriptCode = { - execute: 'first-function-code\nsecond-function-code', - revert: 'first-function-revert-code\nsecond-function-revert-code', - }; - const sut = new ScriptCompiler([firstFunction, secondFunction]); - const call: FunctionCallData[] = [ - { function: firstFunction.name }, - { function: secondFunction.name }, - ]; - const script = ScriptDataStub.createWithCall(call); - // act - const actual = sut.compile(script); - // assert - expect(actual).to.deep.equal(expected); - }); - }); - describe('parameter substitution', () => { - describe('substitutes as expected', () => { - it('with different parameters', () => { - // arrange - const env = new TestEnvironment({ - code: 'He{{ $firstParameter }} {{ $secondParameter }}!', - parameters: { - firstParameter: 'llo', - secondParameter: 'world', - }, - }); - const expected = env.expect('Hello world!'); - // act - const actual = env.sut.compile(env.script); - // assert - expect(actual).to.deep.equal(expected); - }); - it('with single parameter', () => { - // arrange - const env = new TestEnvironment({ - code: '{{ $parameter }}!', - parameters: { - parameter: 'Hodor', - }, - }); - const expected = env.expect('Hodor!'); - // act - const actual = env.sut.compile(env.script); - // assert - expect(actual).to.deep.equal(expected); - }); - }); - it('throws when parameters is undefined', () => { - // arrange - const env = new TestEnvironment({ - code: '{{ $parameter }} {{ $parameter }}!', - }); - const expectedError = 'no parameters defined, expected: "parameter"'; - // act - const act = () => env.sut.compile(env.script); - // assert - expect(act).to.throw(expectedError); - }); - it('throws when parameter value is not provided', () => { - // arrange - const env = new TestEnvironment({ - code: '{{ $parameter }} {{ $parameter }}!', - parameters: { - parameter: undefined, - }, - }); - const expectedError = 'parameter value is not provided for "parameter" in function call'; - // act - const act = () => env.sut.compile(env.script); - // assert - expect(act).to.throw(expectedError); - }); - }); - }); -}); - -interface ITestCase { - code: string; - parameters?: FunctionCallParametersData; -} - -class TestEnvironment { - public readonly sut: IScriptCompiler; - public readonly script: ScriptData; - constructor(testCase: ITestCase) { - const functionName = 'testFunction'; - const func: FunctionData = { - name: functionName, - parameters: testCase.parameters ? Object.keys(testCase.parameters) : undefined, - code: this.getCode(testCase.code, 'execute'), - revertCode: this.getCode(testCase.code, 'revert'), - }; - this.sut = new ScriptCompiler([func]); - const call: FunctionCallData = { - function: functionName, - parameters: testCase.parameters, - }; - this.script = ScriptDataStub.createWithCall(call); - } - public expect(code: string): IScriptCode { - return { - execute: this.getCode(code, 'execute'), - revert: this.getCode(code, 'revert'), - }; - } - private getCode(text: string, type: 'execute' | 'revert'): string { - return `${text} (${type})`; - } -} - -function createFunctions(...names: string[]): FunctionData[] { - if (!names || names.length === 0) { - names = ['test-function']; - } - return names.map((functionName) => { - const func: FunctionData = { - name: functionName, - parameters: [], - code: `REM test-code (${functionName})`, - revertCode: `REM test-revert-code (${functionName})`, - }; - return func; - }); -} diff --git a/tests/unit/application/Parser/Script/CategoryCollectionParseContext.spec.ts b/tests/unit/application/Parser/Script/CategoryCollectionParseContext.spec.ts new file mode 100644 index 00000000..86fb9ff3 --- /dev/null +++ b/tests/unit/application/Parser/Script/CategoryCollectionParseContext.spec.ts @@ -0,0 +1,82 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ISyntaxFactory } from '@/application/Parser/Script/Syntax/ISyntaxFactory'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { LanguageSyntaxStub } from '../../../stubs/LanguageSyntaxStub'; +import { CategoryCollectionParseContext } from '@/application/Parser/Script/CategoryCollectionParseContext'; +import { ScriptingDefinitionStub } from '../../../stubs/ScriptingDefinitionStub'; +import { FunctionDataStub } from '../../../stubs/FunctionDataStub'; +import { ILanguageSyntax } from '@/domain/ScriptCode'; +import { ScriptCompiler } from '@/application/Parser/Script/Compiler/ScriptCompiler'; +import { FunctionData } from 'js-yaml-loader!*'; + +describe('CategoryCollectionParseContext', () => { + describe('ctor', () => { + describe('functionsData', () => { + it('can create with empty values', () => { + // arrange + const testData: FunctionData[][] = [ undefined, [] ]; + const scripting = new ScriptingDefinitionStub(); + for (const functionsData of testData) { + // act + const act = () => new CategoryCollectionParseContext(functionsData, scripting); + // assert + expect(act).to.not.throw(); + } + }); + }); + it('scripting', () => { + // arrange + const expectedError = 'undefined scripting'; + const scripting = undefined; + const functionsData = [ new FunctionDataStub() ]; + // act + const act = () => new CategoryCollectionParseContext(functionsData, scripting); + // assert + expect(act).to.throw(expectedError); + }); + }); + describe('compiler', () => { + it('constructed as expected', () => { + // arrange + const functionsData = [ new FunctionDataStub() ]; + const syntax = new LanguageSyntaxStub(); + const expected = new ScriptCompiler(functionsData, syntax); + const language = ScriptingLanguage.shellscript; + const factoryMock = mockFactory(language, syntax); + const definition = new ScriptingDefinitionStub() + .withLanguage(language); + // act + const sut = new CategoryCollectionParseContext(functionsData, definition, factoryMock); + const actual = sut.compiler; + // assert + expect(actual).to.deep.equal(expected); + }); + }); + describe('syntax', () => { + it('set from syntax factory', () => { + // arrange + const language = ScriptingLanguage.shellscript; + const expected = new LanguageSyntaxStub(); + const factoryMock = mockFactory(language, expected); + const definition = new ScriptingDefinitionStub() + .withLanguage(language); + // act + const sut = new CategoryCollectionParseContext([], definition, factoryMock); + const actual = sut.syntax; + // assert + expect(actual).to.equal(expected); + }); + }); +}); + +function mockFactory(expectedLanguage: ScriptingLanguage, result: ILanguageSyntax): ISyntaxFactory { + return { + create: (language: ScriptingLanguage) => { + if (language !== expectedLanguage) { + throw new Error('unexpected language'); + } + return result; + }, + }; +} diff --git a/tests/unit/application/Parser/Compiler/ILCode.spec.ts b/tests/unit/application/Parser/Script/Compiler/ILCode.spec.ts similarity index 98% rename from tests/unit/application/Parser/Compiler/ILCode.spec.ts rename to tests/unit/application/Parser/Script/Compiler/ILCode.spec.ts index f6c07991..ecacee4e 100644 --- a/tests/unit/application/Parser/Compiler/ILCode.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/ILCode.spec.ts @@ -1,6 +1,6 @@ import 'mocha'; import { expect } from 'chai'; -import { generateIlCode } from '@/application/Parser/Compiler/ILCode'; +import { generateIlCode } from '@/application/Parser/Script/Compiler/ILCode'; describe('ILCode', () => { describe('getUniqueParameterNames', () => { diff --git a/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts new file mode 100644 index 00000000..6c4e298b --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts @@ -0,0 +1,405 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ScriptCompiler } from '@/application/Parser/Script/Compiler/ScriptCompiler'; +import { FunctionData, ScriptData, FunctionCallData, ScriptFunctionCallData, FunctionCallParametersData } from 'js-yaml-loader!@/*'; +import { IScriptCode } from '@/domain/IScriptCode'; +import { ILanguageSyntax } from '@/domain/ScriptCode'; +import { IScriptCompiler } from '@/application/Parser/Script/Compiler/IScriptCompiler'; +import { LanguageSyntaxStub } from '../../../../stubs/LanguageSyntaxStub'; +import { ScriptDataStub } from '../../../../stubs/ScriptDataStub'; +import { FunctionDataStub } from '../../../../stubs/FunctionDataStub'; + +describe('ScriptCompiler', () => { + describe('ctor', () => { + it('throws if syntax is undefined', () => { + // arrange + const expectedError = `undefined syntax`; + // act + const act = () => new ScriptCompilerBuilder() + .withSomeFunctions() + .withSyntax(undefined) + .build(); + // assert + expect(act).to.throw(expectedError); + }); + it('throws if one of the functions is undefined', () => { + // arrange + const expectedError = `some functions are undefined`; + const functions = [ new FunctionDataStub(), undefined ]; + // act + const act = () => new ScriptCompilerBuilder() + .withFunctions(...functions) + .build(); + // assert + expect(act).to.throw(expectedError); + }); + it('throws when functions have same names', () => { + // arrange + const name = 'same-func-name'; + const expectedError = `duplicate function name: "${name}"`; + const functions = [ + new FunctionDataStub().withName(name), + new FunctionDataStub().withName(name), + ]; + // act + const act = () => new ScriptCompilerBuilder() + .withFunctions(...functions) + .build(); + // assert + expect(act).to.throw(expectedError); + }); + it('throws when function parameters have same names', () => { + // arrange + const parameterName = 'duplicate-parameter'; + const func = new FunctionDataStub() + .withParameters(parameterName, parameterName); + const expectedError = `"${func.name}": duplicate parameter name: "${parameterName}"`; + // act + const act = () => new ScriptCompilerBuilder() + .withFunctions(func) + .build(); + // assert + expect(act).to.throw(expectedError); + }); + describe('throws when when function have duplicate code', () => { + it('code', () => { + // arrange + const code = 'duplicate-code'; + const expectedError = `duplicate "code" in functions: "${code}"`; + const functions = [ + new FunctionDataStub().withName('func-1').withCode(code), + new FunctionDataStub().withName('func-2').withCode(code), + ]; + // act + const act = () => new ScriptCompilerBuilder() + .withFunctions(...functions) + .build(); + // assert + expect(act).to.throw(expectedError); + }); + it('revertCode', () => { + // arrange + const revertCode = 'duplicate-revert-code'; + const expectedError = `duplicate "revertCode" in functions: "${revertCode}"`; + const functions = [ + new FunctionDataStub().withName('func-1').withCode('code-1').withRevertCode(revertCode), + new FunctionDataStub().withName('func-2').withCode('code-2').withRevertCode(revertCode), + ]; + // act + const act = () => new ScriptCompilerBuilder() + .withFunctions(...functions) + .build(); + // assert + expect(act).to.throw(expectedError); + }); + }); + it('can construct with empty functions', () => { + // arrange + const builder = new ScriptCompilerBuilder() + .withEmptyFunctions(); + // act + const act = () => builder.build(); + // assert + expect(act).to.not.throw(); + }); + }); + describe('canCompile', () => { + it('returns true if "call" is defined', () => { + // arrange + const sut = new ScriptCompilerBuilder() + .withEmptyFunctions() + .build(); + const script = ScriptDataStub.createWithCall(); + // act + const actual = sut.canCompile(script); + // assert + expect(actual).to.equal(true); + }); + it('returns false if "call" is undefined', () => { + // arrange + const sut = new ScriptCompilerBuilder() + .withEmptyFunctions() + .build(); + const script = ScriptDataStub.createWithCode(); + // act + const actual = sut.canCompile(script); + // assert + expect(actual).to.equal(false); + }); + }); + describe('compile', () => { + describe('invalid state', () => { + it('throws if functions are empty', () => { + // arrange + const expectedError = 'cannot compile without shared functions'; + const sut = new ScriptCompilerBuilder() + .withEmptyFunctions() + .build(); + const script = ScriptDataStub.createWithCall(); + // act + const act = () => sut.compile(script); + // assert + expect(act).to.throw(expectedError); + }); + it('throws if call is not an object', () => { + // arrange + const expectedError = 'called function(s) must be an object'; + const invalidValues = [undefined, 'string', 33]; + const sut = new ScriptCompilerBuilder() + .withSomeFunctions() + .build(); + invalidValues.forEach((invalidValue) => { + const script = ScriptDataStub.createWithoutCallOrCodes() // because call ctor overwrites "undefined" + .withCall(invalidValue as any); + // act + const act = () => sut.compile(script); + // assert + expect(act).to.throw(expectedError); + }); + }); + describe('invalid function reference', () => { + it('throws if function does not exist', () => { + // arrange + const sut = new ScriptCompilerBuilder() + .withSomeFunctions() + .build(); + const nonExistingFunctionName = 'non-existing-func'; + const expectedError = `called function is not defined "${nonExistingFunctionName}"`; + const call: ScriptFunctionCallData = { function: nonExistingFunctionName }; + const script = ScriptDataStub.createWithCall(call); + // act + const act = () => sut.compile(script); + // assert + expect(act).to.throw(expectedError); + }); + it('throws if function is undefined', () => { + // arrange + const existingFunctionName = 'existing-func'; + const sut = new ScriptCompilerBuilder() + .withFunctionNames(existingFunctionName) + .build(); + const call: ScriptFunctionCallData = [ + { function: existingFunctionName }, + undefined, + ]; + const script = ScriptDataStub.createWithCall(call); + const expectedError = `undefined function call in script "${script.name}"`; + // act + const act = () => sut.compile(script); + // assert + expect(act).to.throw(expectedError); + }); + it('throws if function name is not given', () => { + // arrange + const existingFunctionName = 'existing-func'; + const sut = new ScriptCompilerBuilder() + .withFunctionNames(existingFunctionName) + .build(); + const call: FunctionCallData[] = [ + { function: existingFunctionName }, + { function: undefined }]; + const script = ScriptDataStub.createWithCall(call); + const expectedError = `empty function name called in script "${script.name}"`; + // act + const act = () => sut.compile(script); + // assert + expect(act).to.throw(expectedError); + }); + }); + }); + describe('builds code as expected', () => { + it('creates code with expected syntax', () => { // test through script validation logic + // act + const commentDelimiter = 'should not throw'; + const syntax = new LanguageSyntaxStub().withCommentDelimiters(commentDelimiter); + const func = new FunctionDataStub() + .withCode(`${commentDelimiter} duplicate-line\n${commentDelimiter} duplicate-line`); + const sut = new ScriptCompilerBuilder() + .withFunctions(func) + .withSyntax(syntax) + .build(); + const call: FunctionCallData = { function: func.name }; + const script = ScriptDataStub.createWithCall(call); + // act + const act = () => sut.compile(script); + // assert + expect(act).to.not.throw(); + }); + it('builds single call as expected', () => { + // arrange + const functionName = 'testSharedFunction'; + const expectedExecute = `expected-execute`; + const expectedRevert = `expected-revert`; + const func = new FunctionDataStub() + .withName(functionName) + .withCode(expectedExecute) + .withRevertCode(expectedRevert); + const sut = new ScriptCompilerBuilder() + .withFunctions(func) + .build(); + const call: FunctionCallData = { function: functionName }; + const script = ScriptDataStub.createWithCall(call); + // act + const actual = sut.compile(script); + // assert + expect(actual.execute).to.equal(expectedExecute); + expect(actual.revert).to.equal(expectedRevert); + }); + it('builds call sequence as expected', () => { + // arrange + const firstFunction = new FunctionDataStub() + .withName('first-function-name') + .withCode('first-function-code') + .withRevertCode('first-function-revert-code'); + const secondFunction = new FunctionDataStub() + .withName('second-function-name') + .withCode('second-function-code') + .withRevertCode('second-function-revert-code'); + const expectedExecute = `${firstFunction.code}\n${secondFunction.code}`; + const expectedRevert = `${firstFunction.revertCode}\n${secondFunction.revertCode}`; + const sut = new ScriptCompilerBuilder() + .withFunctions(firstFunction, secondFunction) + .build(); + const call: FunctionCallData[] = [ + { function: firstFunction.name }, + { function: secondFunction.name }, + ]; + const script = ScriptDataStub.createWithCall(call); + // act + const actual = sut.compile(script); + // assert + expect(actual.execute).to.equal(expectedExecute); + expect(actual.revert).to.equal(expectedRevert); + }); + }); + describe('parameter substitution', () => { + describe('substitutes as expected', () => { + it('with different parameters', () => { + // arrange + const env = new TestEnvironment({ + code: 'He{{ $firstParameter }} {{ $secondParameter }}!', + parameters: { + firstParameter: 'llo', + secondParameter: 'world', + }, + }); + const expected = env.expect('Hello world!'); + // act + const actual = env.sut.compile(env.script); + // assert + expect(actual).to.deep.equal(expected); + }); + it('with single parameter', () => { + // arrange + const env = new TestEnvironment({ + code: '{{ $parameter }}!', + parameters: { + parameter: 'Hodor', + }, + }); + const expected = env.expect('Hodor!'); + // act + const actual = env.sut.compile(env.script); + // assert + expect(actual).to.deep.equal(expected); + }); + }); + it('throws when parameters is undefined', () => { + // arrange + const env = new TestEnvironment({ + code: '{{ $parameter }} {{ $parameter }}!', + }); + const expectedError = 'no parameters defined, expected: "parameter"'; + // act + const act = () => env.sut.compile(env.script); + // assert + expect(act).to.throw(expectedError); + }); + it('throws when parameter value is not provided', () => { + // arrange + const env = new TestEnvironment({ + code: '{{ $parameter }} {{ $parameter }}!', + parameters: { + parameter: undefined, + }, + }); + const expectedError = 'parameter value is not provided for "parameter" in function call'; + // act + const act = () => env.sut.compile(env.script); + // assert + expect(act).to.throw(expectedError); + }); + }); + interface ITestCase { + code: string; + parameters?: FunctionCallParametersData; + } + class TestEnvironment { + public readonly sut: IScriptCompiler; + public readonly script: ScriptData; + constructor(testCase: ITestCase) { + const functionName = 'testFunction'; + const parameters = testCase.parameters ? Object.keys(testCase.parameters) : []; + const func = new FunctionDataStub() + .withName(functionName) + .withParameters(...parameters) + .withCode(this.getCode(testCase.code, 'execute')) + .withRevertCode(this.getCode(testCase.code, 'revert')); + const syntax = new LanguageSyntaxStub(); + this.sut = new ScriptCompiler([func], syntax); + const call: FunctionCallData = { + function: functionName, + parameters: testCase.parameters, + }; + this.script = ScriptDataStub.createWithCall(call); + } + public expect(code: string): IScriptCode { + return { + execute: this.getCode(code, 'execute'), + revert: this.getCode(code, 'revert'), + }; + } + private getCode(text: string, type: 'execute' | 'revert'): string { + return `${text} (${type})`; + } + } + }); +}); + + +// tslint:disable-next-line:max-classes-per-file +class ScriptCompilerBuilder { + private static createFunctions(...names: string[]): FunctionData[] { + return names.map((functionName) => { + return new FunctionDataStub().withName(functionName); + }); + } + private functions: FunctionData[]; + private syntax: ILanguageSyntax = new LanguageSyntaxStub(); + public withFunctions(...functions: FunctionData[]): ScriptCompilerBuilder { + this.functions = functions; + return this; + } + public withSomeFunctions(): ScriptCompilerBuilder { + this.functions = ScriptCompilerBuilder.createFunctions('test-function'); + return this; + } + public withFunctionNames(...functionNames: string[]): ScriptCompilerBuilder { + this.functions = ScriptCompilerBuilder.createFunctions(...functionNames); + return this; + } + public withEmptyFunctions(): ScriptCompilerBuilder { + this.functions = []; + return this; + } + public withSyntax(syntax: ILanguageSyntax): ScriptCompilerBuilder { + this.syntax = syntax; + return this; + } + public build(): ScriptCompiler { + if (!this.functions) { + throw new Error('Function behavior not defined'); + } + return new ScriptCompiler(this.functions, this.syntax); + } +} diff --git a/tests/unit/application/Parser/ScriptParser.spec.ts b/tests/unit/application/Parser/Script/ScriptParser.spec.ts similarity index 59% rename from tests/unit/application/Parser/ScriptParser.spec.ts rename to tests/unit/application/Parser/Script/ScriptParser.spec.ts index 40b1f036..060a2916 100644 --- a/tests/unit/application/Parser/ScriptParser.spec.ts +++ b/tests/unit/application/Parser/Script/ScriptParser.spec.ts @@ -1,12 +1,15 @@ import 'mocha'; import { expect } from 'chai'; -import { parseScript } from '@/application/Parser/ScriptParser'; +import { parseScript } from '@/application/Parser/Script/ScriptParser'; import { parseDocUrls } from '@/application/Parser/DocumentationParser'; import { RecommendationLevel } from '@/domain/RecommendationLevel'; -import { ScriptCode } from '@/domain/ScriptCode'; -import { ScriptCompilerStub } from '../../stubs/ScriptCompilerStub'; -import { ScriptDataStub } from '../../stubs/ScriptDataStub'; -import { mockEnumParser } from '../../stubs/EnumParserStub'; +import { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext'; +import { ScriptCompilerStub } from '../../../stubs/ScriptCompilerStub'; +import { ScriptDataStub } from '../../../stubs/ScriptDataStub'; +import { mockEnumParser } from '../../../stubs/EnumParserStub'; +import { ScriptCodeStub } from '../../../stubs/ScriptCodeStub'; +import { CategoryCollectionParseContextStub } from '../../../stubs/CategoryCollectionParseContextStub'; +import { LanguageSyntaxStub } from '../../../stubs/LanguageSyntaxStub'; describe('ScriptParser', () => { describe('parseScript', () => { @@ -15,9 +18,9 @@ describe('ScriptParser', () => { const expected = 'test-expected-name'; const script = ScriptDataStub.createWithCode() .withName(expected); - const compiler = new ScriptCompilerStub(); + const parseContext = new CategoryCollectionParseContextStub(); // act - const actual = parseScript(script, compiler); + const actual = parseScript(script, parseContext); // assert expect(actual.name).to.equal(expected); }); @@ -26,10 +29,10 @@ describe('ScriptParser', () => { const docs = [ 'https://expected-doc1.com', 'https://expected-doc2.com' ]; const script = ScriptDataStub.createWithCode() .withDocs(docs); - const compiler = new ScriptCompilerStub(); + const parseContext = new CategoryCollectionParseContextStub(); const expected = parseDocUrls(script); // act - const actual = parseScript(script, compiler); + const actual = parseScript(script, parseContext); // assert expect(actual.documentationUrls).to.deep.equal(expected); }); @@ -37,44 +40,44 @@ describe('ScriptParser', () => { it('throws when script is undefined', () => { // arrange const expectedError = 'undefined script'; - const compiler = new ScriptCompilerStub(); + const parseContext = new CategoryCollectionParseContextStub(); const script = undefined; // act - const act = () => parseScript(script, compiler); + 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 compiler = new ScriptCompilerStub(); + const parseContext = new CategoryCollectionParseContextStub(); const script = ScriptDataStub .createWithCall() .withCode('code'); // act - const act = () => parseScript(script, compiler); + 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 compiler = new ScriptCompilerStub(); + const parseContext = new CategoryCollectionParseContextStub(); const script = ScriptDataStub .createWithCall() .withRevertCode('revert-code'); // act - const act = () => parseScript(script, compiler); + 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 compiler = new ScriptCompilerStub(); + const parseContext = new CategoryCollectionParseContextStub(); const script = ScriptDataStub.createWithoutCallOrCodes(); // act - const act = () => parseScript(script, compiler); + const act = () => parseScript(script, parseContext); // assert expect(act).to.throw(expectedError); }); @@ -84,11 +87,11 @@ describe('ScriptParser', () => { const undefinedLevels: string[] = [ '', undefined ]; undefinedLevels.forEach((undefinedLevel) => { // arrange - const compiler = new ScriptCompilerStub(); + const parseContext = new CategoryCollectionParseContextStub(); const script = ScriptDataStub.createWithCode() .withRecommend(undefinedLevel); // act - const actual = parseScript(script, compiler); + const actual = parseScript(script, parseContext); // assert expect(actual.level).to.equal(undefined); }); @@ -100,10 +103,10 @@ describe('ScriptParser', () => { const levelText = 'standard'; const script = ScriptDataStub.createWithCode() .withRecommend(levelText); - const compiler = new ScriptCompilerStub(); + const parseContext = new CategoryCollectionParseContextStub(); const parserMock = mockEnumParser(expectedName, levelText, expectedLevel); // act - const actual = parseScript(script, compiler, parserMock); + const actual = parseScript(script, parseContext, parserMock); // assert expect(actual.level).to.equal(expectedLevel); }); @@ -115,9 +118,9 @@ describe('ScriptParser', () => { const script = ScriptDataStub .createWithCode() .withCode(expected); - const compiler = new ScriptCompilerStub(); + const parseContext = new CategoryCollectionParseContextStub(); // act - const parsed = parseScript(script, compiler); + const parsed = parseScript(script, parseContext); // assert const actual = parsed.code.execute; expect(actual).to.equal(expected); @@ -128,36 +131,55 @@ describe('ScriptParser', () => { const script = ScriptDataStub .createWithCode() .withRevertCode(expected); - const compiler = new ScriptCompilerStub(); + const parseContext = new CategoryCollectionParseContextStub(); // act - const parsed = parseScript(script, compiler); + const parsed = parseScript(script, parseContext); // assert const actual = parsed.code.revert; expect(actual).to.equal(expected); }); describe('compiler', () => { - it('throws when compiler is not defined', () => { + it('throws when context is not defined', () => { // arrange + const expectedMessage = 'undefined context'; const script = ScriptDataStub.createWithCode(); - const compiler = undefined; + const context: ICategoryCollectionParseContext = undefined; // act - const act = () => parseScript(script, compiler); + const act = () => parseScript(script, context); // assert - expect(act).to.throw('undefined compiler'); + expect(act).to.throw(expectedMessage); }); it('gets code from compiler', () => { // arrange - const expected = new ScriptCode('test-script', 'code', 'revert-code'); + const expected = new ScriptCodeStub(); const script = ScriptDataStub.createWithCode(); const compiler = new ScriptCompilerStub() .withCompileAbility(script, expected); + const parseContext = new CategoryCollectionParseContextStub() + .withCompiler(compiler); // act - const parsed = parseScript(script, compiler); + const parsed = parseScript(script, parseContext); // assert const actual = parsed.code; expect(actual).to.equal(expected); }); }); + describe('syntax', () => { + it('set from the context', () => { // test through script validation logic + // arrange + const commentDelimiter = 'should not throw'; + const duplicatedCode = `${commentDelimiter} duplicate-line\n${commentDelimiter} duplicate-line`; + const parseContext = new CategoryCollectionParseContextStub() + .withSyntax(new LanguageSyntaxStub().withCommentDelimiters(commentDelimiter)); + const script = ScriptDataStub + .createWithoutCallOrCodes() + .withCode(duplicatedCode); + // act + const act = () => parseScript(script, parseContext); + // assert + expect(act).to.not.throw(); + }); + }); }); }); }); diff --git a/tests/unit/application/Parser/Script/Syntax/ConcreteSyntaxes.spec.ts b/tests/unit/application/Parser/Script/Syntax/ConcreteSyntaxes.spec.ts new file mode 100644 index 00000000..9fad3329 --- /dev/null +++ b/tests/unit/application/Parser/Script/Syntax/ConcreteSyntaxes.spec.ts @@ -0,0 +1,33 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ILanguageSyntax } from '@/domain/ScriptCode'; +import { BatchFileSyntax } from '@/application/Parser/Script/Syntax/BatchFileSyntax'; +import { ShellScriptSyntax } from '@/application/Parser/Script/Syntax/ShellScriptSyntax'; + + +function getSystemsUnderTest(): ILanguageSyntax[] { + return [ new BatchFileSyntax(), new ShellScriptSyntax() ]; +} + +describe('ConcreteSyntaxes', () => { + describe('commentDelimiters', () => { + for (const sut of getSystemsUnderTest()) { + it(`${sut.constructor.name} returns defined value`, () => { + // act + const value = sut.commentDelimiters; + // assert + expect(value); + }); + } + }); + describe('commonCodeParts', () => { + for (const sut of getSystemsUnderTest()) { + it(`${sut.constructor.name} returns defined value`, () => { + // act + const value = sut.commonCodeParts; + // assert + expect(value); + }); + } + }); +}); diff --git a/tests/unit/application/Parser/Script/Syntax/SyntaxFactory.spec.ts b/tests/unit/application/Parser/Script/Syntax/SyntaxFactory.spec.ts new file mode 100644 index 00000000..7d742b37 --- /dev/null +++ b/tests/unit/application/Parser/Script/Syntax/SyntaxFactory.spec.ts @@ -0,0 +1,38 @@ +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'; + +describe('SyntaxFactory', () => { + describe('getSyntax', () => { + describe('creates expected type', () => { + it('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]}"`); + }); + }); +}); diff --git a/tests/unit/application/Parser/ScriptingDefinitionParser.spec.ts b/tests/unit/application/Parser/ScriptingDefinitionParser.spec.ts index 82d24b73..0a1664e0 100644 --- a/tests/unit/application/Parser/ScriptingDefinitionParser.spec.ts +++ b/tests/unit/application/Parser/ScriptingDefinitionParser.spec.ts @@ -34,7 +34,8 @@ describe('ScriptingDefinitionParser', () => { const expectedName = 'language'; const info = new ProjectInformationStub(); const definition = new ScriptingDefinitionBuilder() - .withLanguage(languageText).construct(); + .withLanguage(languageText) + .construct(); const parserMock = mockEnumParser(expectedName, languageText, expectedLanguage); // act const actual = parseScriptingDefinition(definition, info, new Date(), parserMock); diff --git a/tests/unit/domain/CategoryCollection.spec.ts b/tests/unit/domain/CategoryCollection.spec.ts index b71b8f06..eb27a1ea 100644 --- a/tests/unit/domain/CategoryCollection.spec.ts +++ b/tests/unit/domain/CategoryCollection.spec.ts @@ -1,6 +1,5 @@ import 'mocha'; import { expect } from 'chai'; -import { ProjectInformation } from '@/domain/ProjectInformation'; import { ICategory } from '@/domain/ICategory'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; diff --git a/tests/unit/domain/Script.spec.ts b/tests/unit/domain/Script.spec.ts index c24a0d69..02477959 100644 --- a/tests/unit/domain/Script.spec.ts +++ b/tests/unit/domain/Script.spec.ts @@ -3,16 +3,15 @@ import 'mocha'; import { expect } from 'chai'; import { Script } from '@/domain/Script'; import { RecommendationLevel } from '@/domain/RecommendationLevel'; -import { ScriptCode } from '@/domain/ScriptCode'; import { IScriptCode } from '@/domain/IScriptCode'; +import { ScriptCodeStub } from '../stubs/ScriptCodeStub'; describe('Script', () => { describe('ctor', () => { describe('scriptCode', () => { it('sets as expected', () => { // arrange - const name = 'test-script'; - const expected = new ScriptCode(name, 'expected-execute', 'expected-revert'); + const expected = new ScriptCodeStub(); const sut = new ScriptBuilder() .withCode(expected) .build(); @@ -110,12 +109,14 @@ describe('Script', () => { class ScriptBuilder { private name = 'test-script'; - private code: IScriptCode = new ScriptCode(this.name, 'code', 'revert-code'); + private code: IScriptCode = new ScriptCodeStub(); private level = RecommendationLevel.Standard; private documentationUrls: readonly string[] = undefined; public withCodes(code: string, revertCode = ''): ScriptBuilder { - this.code = new ScriptCode(this.name, code, revertCode); + this.code = new ScriptCodeStub() + .withExecute(code) + .withRevert(revertCode); return this; } diff --git a/tests/unit/domain/ScriptCode.spec.ts b/tests/unit/domain/ScriptCode.spec.ts index 47a8cbf1..c130f0a5 100644 --- a/tests/unit/domain/ScriptCode.spec.ts +++ b/tests/unit/domain/ScriptCode.spec.ts @@ -2,6 +2,8 @@ import 'mocha'; import { expect } from 'chai'; import { ScriptCode } from '@/domain/ScriptCode'; import { IScriptCode } from '@/domain/IScriptCode'; +import { ILanguageSyntax } from '@/domain/ScriptCode'; +import { LanguageSyntaxStub } from '../stubs/LanguageSyntaxStub'; describe('ScriptCode', () => { describe('scriptName', () => { @@ -10,7 +12,9 @@ describe('ScriptCode', () => { const expectedError = 'name is undefined'; const name = undefined; // act - const act = () => new ScriptCode(name, 'non-empty-code', ''); + const act = () => new ScriptCodeBuilder() + .withName(name) + .build(); // assert expect(act).to.throw(expectedError); }); @@ -48,7 +52,11 @@ describe('ScriptCode', () => { for (const testCase of testCases) { it(testCase.name, () => { // act - const act = () => new ScriptCode(scriptName, testCase.code.execute, testCase.code.revert); + const act = () => new ScriptCodeBuilder() + .withName(scriptName) + .withExecute( testCase.code.execute) + .withRevert(testCase.code.revert) + .build(); // assert expect(act).to.throw(testCase.expectedError); }); @@ -72,15 +80,21 @@ describe('ScriptCode', () => { // act const actions = []; for (const testCase of testCases) { - const substituteScriptName = (name) => testCase.expectedMessage.replace('$scriptName', name); + const substituteScriptName = (name: string) => testCase.expectedMessage.replace('$scriptName', name); actions.push(...[ { - act: () => new ScriptCode(scriptName, testCase.code, undefined), + act: () => new ScriptCodeBuilder() + .withName(scriptName) + .withExecute(testCase.code) + .build(), testName: `execute: ${testCase.testName}`, expectedMessage: substituteScriptName(scriptName), }, { - act: () => new ScriptCode(scriptName, 'valid code', testCase.code), + act: () => new ScriptCodeBuilder() + .withName(scriptName) + .withRevert(testCase.code) + .build(), testName: `revert: ${testCase.testName}`, expectedMessage: substituteScriptName(`${scriptName} (revert)`), }, @@ -96,26 +110,29 @@ describe('ScriptCode', () => { }); describe('sets as expected with valid "execute" or "revert"', () => { // arrange + const syntax = new LanguageSyntaxStub() + .withCommonCodeParts(')', 'else', '(') + .withCommentDelimiters('#', '//'); const testCases = [ { testName: 'code is a valid string', code: 'valid code', }, { - testName: 'code consists of frequent code parts', - code: ') else (', + testName: 'code consists of common code parts', + code: syntax.commonCodeParts.join(' '), }, { - testName: 'code is a frequent code part', - code: ')', + testName: 'code is a common code part', + code: syntax.commonCodeParts[0], }, { - testName: 'code with duplicated comment lines (::)', - code: ':: comment\n:: comment', + testName: `code with duplicated comment lines (${syntax.commentDelimiters[0]})`, + code: `${syntax.commentDelimiters[0]} comment\n${syntax.commentDelimiters[0]} comment`, }, { - testName: 'code with duplicated comment lines (REM)', - code: 'REM comment\nREM comment', + testName: `code with duplicated comment lines (${syntax.commentDelimiters[1]})`, + code: `${syntax.commentDelimiters[1]} comment\n${syntax.commentDelimiters[1]} comment`, }, ]; // act @@ -124,12 +141,20 @@ describe('ScriptCode', () => { actions.push(...[ { testName: `execute: ${testCase.testName}`, - act: () => createSut(testCase.code), + act: () => + new ScriptCodeBuilder() + .withSyntax(syntax) + .withExecute(testCase.code) + .build(), expect: (sut: IScriptCode) => sut.execute === testCase.code, }, { testName: `revert: ${testCase.testName}`, - act: () => createSut('different code', testCase.code), + act: () => + new ScriptCodeBuilder() + .withSyntax(syntax) + .withRevert(testCase.code) + .build(), expect: (sut: IScriptCode) => sut.revert === testCase.code, }, ]); @@ -145,6 +170,34 @@ describe('ScriptCode', () => { }); }); -function createSut(code: string, revert = ''): ScriptCode { - return new ScriptCode('test-code', code, revert); +class ScriptCodeBuilder { + public execute = 'default-execute-code'; + public revert = ''; + public scriptName = 'default-script-name'; + public syntax: ILanguageSyntax = new LanguageSyntaxStub(); + + public withName(name: string) { + this.scriptName = name; + return this; + } + public withExecute(execute: string) { + this.execute = execute; + return this; + } + public withRevert(revert: string) { + this.revert = revert; + return this; + } + public withSyntax(syntax: ILanguageSyntax) { + this.syntax = syntax; + return this; + } + + public build(): ScriptCode { + return new ScriptCode( + this.execute, + this.revert, + this.scriptName, + this.syntax); + } } diff --git a/tests/unit/domain/ScriptingDefinition.spec.ts b/tests/unit/domain/ScriptingDefinition.spec.ts index fcc90a96..2b595b66 100644 --- a/tests/unit/domain/ScriptingDefinition.spec.ts +++ b/tests/unit/domain/ScriptingDefinition.spec.ts @@ -3,7 +3,6 @@ import { expect } from 'chai'; import { ScriptingDefinition } from '@/domain/ScriptingDefinition'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { getEnumValues } from '@/application/Common/Enum'; -import { OperatingSystem } from '@/domain/OperatingSystem'; describe('ScriptingDefinition', () => { describe('language', () => { @@ -38,7 +37,7 @@ describe('ScriptingDefinition', () => { // arrange const testCases = new Map([ [ScriptingLanguage.batchfile, 'bat'], - [ScriptingLanguage.bash, 'sh'], + [ScriptingLanguage.shellscript, 'sh'], ]); Array.from(testCases.entries()).forEach((test) => { const language = test[0]; @@ -108,7 +107,7 @@ describe('ScriptingDefinition', () => { }); class ScriptingDefinitionBuilder { - private language = ScriptingLanguage.bash; + private language = ScriptingLanguage.shellscript; private startCode = 'REM start-code'; private endCode = 'REM end-code'; diff --git a/tests/unit/infrastructure/AsyncLazy.spec.ts b/tests/unit/infrastructure/AsyncLazy.spec.ts index c7c4aa16..2f7345fa 100644 --- a/tests/unit/infrastructure/AsyncLazy.spec.ts +++ b/tests/unit/infrastructure/AsyncLazy.spec.ts @@ -35,10 +35,10 @@ describe('AsyncLazy', () => { expect(results).to.deep.equal([1, 1, 1, 1, 1]); }); - it('when running long-running task paralelly', async () => { - const sleep = (time: number) => new Promise(((resolve) => setTimeout(resolve, time))); + it('when running long-running task in parallel', async () => { + const sleepAsync = (time: number) => new Promise(((resolve) => setTimeout(resolve, time))); const sut = new AsyncLazy(async () => { - await sleep(100); + await sleepAsync(100); totalExecuted++; return Promise.resolve(totalExecuted); }); diff --git a/tests/unit/infrastructure/Signal.spec.ts b/tests/unit/infrastructure/Signal.spec.ts index dfd37991..95e39c2c 100644 --- a/tests/unit/infrastructure/Signal.spec.ts +++ b/tests/unit/infrastructure/Signal.spec.ts @@ -1,81 +1,87 @@ +import { ISignal } from '@/infrastructure/Events/ISignal'; +import { IEventSubscription } from '@/infrastructure/Events/ISubscription'; import { Signal } from '@/infrastructure/Events/Signal'; import { expect } from 'chai'; +import { EventHandler } from '@/infrastructure/Events/ISignal'; + describe('Signal', () => { - class ReceiverMock { - public onRecieveCalls = new Array(); - public onReceive(arg: number): void { this.onRecieveCalls.push(arg); } + class ObserverMock { + public readonly onReceiveCalls = new Array(); + public readonly callbacks = new Array>(); + public readonly subscription: IEventSubscription; + constructor(subject: ISignal) { + this.callbacks.push((arg) => this.onReceiveCalls.push(arg)); + this.subscription = subject.on((arg) => this.callbacks.forEach((action) => action(arg))); + } } - let signal: Signal; beforeEach(() => signal = new Signal()); - - describe('single reciever', () => { - let receiver: ReceiverMock; - + describe('single observer', () => { + // arrange + let observer: ObserverMock; beforeEach(() => { - receiver = new ReceiverMock(); - signal.on((arg) => receiver.onReceive(arg)); + observer = new ObserverMock(signal); }); - it('notify() executes the callback', () => { + // act signal.notify(5); - expect(receiver.onRecieveCalls).to.have.length(1); + // assert + expect(observer.onReceiveCalls).to.have.length(1); }); - it('notify() executes the callback with the payload', () => { const expected = 5; + // act signal.notify(expected); - expect(receiver.onRecieveCalls).to.deep.equal([expected]); + // assert + expect(observer.onReceiveCalls).to.deep.equal([expected]); + }); + it('notify() does not call callback when unsubscribed', () => { + // act + observer.subscription.unsubscribe(); + signal.notify(5); + // assert + expect(observer.onReceiveCalls).to.have.lengthOf(0); }); }); - describe('multiple recievers', () => { - let receivers: ReceiverMock[]; - + describe('multiple observers', () => { + // arrange + let observers: ObserverMock[]; beforeEach(() => { - receivers = [ - new ReceiverMock(), new ReceiverMock(), - new ReceiverMock(), new ReceiverMock()]; - function subscribeReceiver(receiver: ReceiverMock) { - signal.on((arg) => receiver.onReceive(arg)); - } - for (const receiver of receivers) { - subscribeReceiver(receiver); - } + observers = [ + new ObserverMock(signal), new ObserverMock(signal), + new ObserverMock(signal), new ObserverMock(signal), + ]; }); - - it('notify() should execute all callbacks', () => { + // act signal.notify(5); - receivers.forEach((receiver) => { - expect(receiver.onRecieveCalls).to.have.length(1); + // assert + observers.forEach((observer) => { + expect(observer.onReceiveCalls).to.have.length(1); }); }); - it('notify() should execute all callbacks with payload', () => { const expected = 5; + // act signal.notify(expected); - receivers.forEach((receiver) => { - expect(receiver.onRecieveCalls).to.deep.equal([expected]); + // assert + observers.forEach((observer) => { + expect(observer.onReceiveCalls).to.deep.equal([expected]); }); }); - it('notify() executes in FIFO order', () => { // arrange const expectedSequence = [0, 1, 2, 3]; const actualSequence = new Array(); - for (let i = 0; i < receivers.length; i++) { - receivers[i].onReceive = ((arg) => { - actualSequence.push(i); - }); + for (let i = 0; i < observers.length; i++) { + observers[i].callbacks.push(() => actualSequence.push(i)); } // act signal.notify(5); // assert expect(actualSequence).to.deep.equal(expectedSequence); }); - }); - }); diff --git a/tests/unit/stubs/CategoryCollectionParseContextStub.ts b/tests/unit/stubs/CategoryCollectionParseContextStub.ts new file mode 100644 index 00000000..b066eb57 --- /dev/null +++ b/tests/unit/stubs/CategoryCollectionParseContextStub.ts @@ -0,0 +1,19 @@ +import { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext'; +import { ScriptCompilerStub } from './ScriptCompilerStub'; +import { LanguageSyntaxStub } from './LanguageSyntaxStub'; +import { IScriptCompiler } from '@/application/Parser/Script/Compiler/IScriptCompiler'; +import { ILanguageSyntax } from '@/domain/ScriptCode'; + +export class CategoryCollectionParseContextStub implements ICategoryCollectionParseContext { + public compiler: IScriptCompiler = new ScriptCompilerStub(); + public syntax: ILanguageSyntax = new LanguageSyntaxStub(); + + public withCompiler(compiler: IScriptCompiler) { + this.compiler = compiler; + return this; + } + public withSyntax(syntax: ILanguageSyntax) { + this.syntax = syntax; + return this; + } +} diff --git a/tests/unit/stubs/CategoryCollectionStub.ts b/tests/unit/stubs/CategoryCollectionStub.ts index 920ed3db..15688b0a 100644 --- a/tests/unit/stubs/CategoryCollectionStub.ts +++ b/tests/unit/stubs/CategoryCollectionStub.ts @@ -30,6 +30,11 @@ export class CategoryCollectionStub implements ICategoryCollection { this.initialScript = script; return this; } + public withTotalScripts(totalScripts: number) { + this.totalScripts = totalScripts; + return this; + } + public findCategory(categoryId: number): ICategory { return this.getAllCategories().find( (category) => category.id === categoryId); diff --git a/tests/unit/stubs/FunctionDataStub.ts b/tests/unit/stubs/FunctionDataStub.ts new file mode 100644 index 00000000..73305108 --- /dev/null +++ b/tests/unit/stubs/FunctionDataStub.ts @@ -0,0 +1,25 @@ +import { FunctionData } from 'js-yaml-loader!*'; + +export class FunctionDataStub implements FunctionData { + public name = 'function data stub'; + public code = 'function data stub code'; + public revertCode = 'function data stub revertCode'; + public parameters?: readonly string[]; + + public withName(name: string) { + this.name = name; + return this; + } + public withParameters(...parameters: string[]) { + this.parameters = parameters; + return this; + } + public withCode(code: string) { + this.code = code; + return this; + } + public withRevertCode(revertCode: string) { + this.revertCode = revertCode; + return this; + } +} diff --git a/tests/unit/stubs/LanguageSyntaxStub.ts b/tests/unit/stubs/LanguageSyntaxStub.ts new file mode 100644 index 00000000..bb3487b0 --- /dev/null +++ b/tests/unit/stubs/LanguageSyntaxStub.ts @@ -0,0 +1,15 @@ +import { ILanguageSyntax } from '@/domain/ScriptCode'; + +export class LanguageSyntaxStub implements ILanguageSyntax { + public commentDelimiters = []; + public commonCodeParts = []; + + public withCommentDelimiters(...delimiters: string[]) { + this.commentDelimiters = delimiters; + return this; + } + public withCommonCodeParts(...codeParts: string[]) { + this.commonCodeParts = codeParts; + return this; + } +} diff --git a/tests/unit/stubs/ScriptCodeStub.ts b/tests/unit/stubs/ScriptCodeStub.ts new file mode 100644 index 00000000..cac3ab55 --- /dev/null +++ b/tests/unit/stubs/ScriptCodeStub.ts @@ -0,0 +1,15 @@ +import { IScriptCode } from '@/domain/IScriptCode'; + +export class ScriptCodeStub implements IScriptCode { + public execute = 'default execute code'; + public revert = 'default revert code'; + + public withExecute(code: string) { + this.execute = code; + return this; + } + public withRevert(revert: string) { + this.revert = revert; + return this; + } +} diff --git a/tests/unit/stubs/ScriptCompilerStub.ts b/tests/unit/stubs/ScriptCompilerStub.ts index 9abcbe28..b0015f81 100644 --- a/tests/unit/stubs/ScriptCompilerStub.ts +++ b/tests/unit/stubs/ScriptCompilerStub.ts @@ -1,4 +1,4 @@ -import { IScriptCompiler } from '@/application/Parser/Compiler/IScriptCompiler'; +import { IScriptCompiler } from '@/application/Parser/Script/Compiler/IScriptCompiler'; import { IScriptCode } from '@/domain/IScriptCode'; import { ScriptData } from 'js-yaml-loader!@/*'; diff --git a/tests/unit/stubs/ScriptDataStub.ts b/tests/unit/stubs/ScriptDataStub.ts index d7d84901..34cf95e7 100644 --- a/tests/unit/stubs/ScriptDataStub.ts +++ b/tests/unit/stubs/ScriptDataStub.ts @@ -27,39 +27,30 @@ export class ScriptDataStub implements ScriptData { public recommend = RecommendationLevel[RecommendationLevel.Standard].toLowerCase(); public docs = ['hello.com']; - private constructor() { } - public withName(name: string): ScriptDataStub { this.name = name; return this; } - public withDocs(docs: string[]): ScriptDataStub { this.docs = docs; return this; } - public withCode(code: string): ScriptDataStub { this.code = code; return this; } - public withRevertCode(revertCode: string): ScriptDataStub { this.revertCode = revertCode; return this; } - public withMockCall(): ScriptDataStub { this.call = { function: 'func', parameters: [] }; return this; } - public withCall(call: ScriptFunctionCallData): ScriptDataStub { this.call = call; return this; } - - public withRecommend(recommend: string): ScriptDataStub { this.recommend = recommend; return this; diff --git a/tests/unit/stubs/ScriptingDefinitionStub.ts b/tests/unit/stubs/ScriptingDefinitionStub.ts index 6997481b..629a43af 100644 --- a/tests/unit/stubs/ScriptingDefinitionStub.ts +++ b/tests/unit/stubs/ScriptingDefinitionStub.ts @@ -15,4 +15,8 @@ export class ScriptingDefinitionStub implements IScriptingDefinition { this.endCode = endCode; return this; } + public withLanguage(language: ScriptingLanguage): ScriptingDefinitionStub { + this.language = language; + return this; + } }