diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2f25280..5322e5c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,17 +23,6 @@ - ❗ DON'T - Do not update the versions, current version is only [set by the maintainer](./img/architecture/gitops.png) and updated automatically by [bump-everywhere](https://github.com/undergroundwires/bump-everywhere) -## Guidelines - -### Handle the state in presentation layer - -- There are two types of components: - - **Stateless**, extends `Vue` - - **Stateful**, extends [`StatefulVue`](./src/presentation/StatefulVue.ts) - - The source of truth for the state lies in application layer ([`./src/application/`](src/application/)) and must be updated from the views if they're mutating the state - - They mutate or/and react to state changes in [ApplicationContext](src/application/Context/ApplicationContext.ts). - - You can react by getting the state and listening to it and update the view accordingly in [`mounted()`](https://vuejs.org/v2/api/#mounted) method. - ## License By contributing, you agree that your contributions will be licensed under its GNU General Public License v3.0. diff --git a/README.md b/README.md index fb07ad56..73b20270 100644 --- a/README.md +++ b/README.md @@ -54,26 +54,14 @@ 1. Build: `docker build -t undergroundwires/privacy.sexy:0.9.1 .` 2. Run: `docker run -it -p 8080:80 --rm --name privacy.sexy-0.9.1 undergroundwires/privacy.sexy:0.9.1` -## Architecture +## Architecture overview ### Application - Powered by **TypeScript**, **Vue.js** and **Electron** 💪 - and driven by **Domain-driven design**, **Event-driven architecture**, **Data-driven programming** concepts. - Application uses highly decoupled models & services in different DDD layers. - - **Domain layer** is where the application is modelled with validation logic. - - **Presentation Layer** - - Consists of Vue.js components and other UI-related code. - - Desktop application is created using [Electron](https://www.electronjs.org/). - - Event driven as in components simply listens to events from the state and act accordingly. - - **Application Layer** - - Keeps the application state using [state pattern](https://en.wikipedia.org/wiki/State_pattern) - - [ApplicationContext](src/application/Context/ApplicationContext.ts) - - Holds the [CategoryCollectionState](src/application/Context/State/CategoryCollectionState.ts)] for each OS - - Same instance is shared throughout the application - - The scripts are defined and controlled in [yaml files](src/application/collections/) per OS - - Uses [data-driven programming](https://en.wikipedia.org/wiki/Data-driven_programming) - - 📖 See [extend scripts](#extend-scripts) to read about how to extend them. +- 📖 Read more on • [Presentation](./docs/presentation.md) • [Application](./docs/application.md) ![DDD + vue.js](img/architecture/app-ddd.png) diff --git a/docs/application.md b/docs/application.md new file mode 100644 index 00000000..f1093683 --- /dev/null +++ b/docs/application.md @@ -0,0 +1,22 @@ +# Application + +- It's mainly responsible for + - creating and event based [application state](#application-state) + - parsing and compiling [application data](#application-data) + +## Application state + +- [ApplicationContext.ts](./../src/application/Context/ApplicationContext.ts) holds the [CategoryCollectionState](./../src/application/Context/State/CategoryCollectionState.ts)] for each OS +- Uses [state pattern](https://en.wikipedia.org/wiki/State_pattern) +- Same instance is shared throughout the application to ensure consistent state +- 📖 See [Application State | Presentation layer](./presentation.md#application-state) to read more about how the state should be managed by the presentation layer. +- 📖 See [ApplicationContext.ts](./../src/application/Context/ApplicationContext.ts) to start diving into the state code. + +## Application data + +- Compiled to `Application` domain object. +- The scripts are defined and controlled in different data files per OS +- Enables [data-driven programming](https://en.wikipedia.org/wiki/Data-driven_programming) and easier contributions +- Application data is defined in collection fil es and +- 📖 See [Application data | Presentation layer](./presentation.md#application-data) to read how the application data is read by the presentation layer. +- 📖 See [collection files documentation](./collection-files.md) to read more about how the data files are structured/defined and see [collection yaml files](./../src/application/collections/) to directly check the code. diff --git a/docs/presentation.md b/docs/presentation.md new file mode 100644 index 00000000..9c984b63 --- /dev/null +++ b/docs/presentation.md @@ -0,0 +1,24 @@ +# Presentation layer + +- Consists of Vue.js components and other UI-related code. +- Desktop application is created using [Electron](https://www.electronjs.org/). +- Event driven as in components simply listens to events from the state and act accordingly. + +## Application data + +- Components and should use [ApplicationFactory](./../src/application/ApplicationFactory.ts) singleton to reach the application domain. +- [Application.ts](../src/domain/Application.ts) domain model is the stateless application representation including + - available scripts, collections as defined in [collection files](./collection-files.md) + - package information as defined in [`package.json`](./../package.json) +- 📖 See [Application data | Application layer](./presentation.md#application-data) where application data is parsed and compiled. + +## Application state + +- Stateful components mutate or/and react to state changes in [ApplicationContext](./../src/application/Context/ApplicationContext.ts). +- Stateless components that does not handle state extends `Vue` +- Stateful components that depends on the collection state such as user selection, search queries and more extends [`StatefulVue`](./../src/presentation/StatefulVue.ts) +- The single source of truth is a singleton of the state created and made available to presentation layer by [`StatefulVue`](./../src/presentation/StatefulVue.ts) +- `StatefulVue` includes abstract `handleCollectionState` that is fired once the component is loaded and also each time [collection](./collection-files.md) is changed. +- Do not forget to subscribe from events when component is destroyed or if needed [collection](./collection-files.md) is changed. + - 💡 `events` in base class [`StatefulVue`](./../src/presentation/StatefulVue.ts) makes lifecycling easier +- 📖 See [Application state | Application layer](./presentation.md#application-state) where the state is implemented using using state pattern. diff --git a/img/architecture/app-ddd.drawio b/img/architecture/app-ddd.drawio index 6a2880fa..4df829db 100644 --- a/img/architecture/app-ddd.drawio +++ b/img/architecture/app-ddd.drawio @@ -1 +1 @@ -3Zpdk6I4FIZ/jZdakPDlpa3tzlTNbnVVV83OzE1XhACZAUKF2Or8+k0wCBhau1f8GrpayUkC5HlzTg7IAE7T9V8M5fHfNMDJABjBegBnAwA8YIpPadhsDS50t4aIkWBrMmvDM/mNldFQ1iUJcNFqyClNOMnbRp9mGfZ5y4YYo6t2s5Am7bPmKMKa4dlHiW79lwQ8VsOyjdr+CZMors5sGqomRVVjZShiFNBVwwQfB3DKKOXbvXQ9xYlkV3HZ9pu/Ubu7MIYz/p4OD0/efJxMXmZfs6kd/vj2Txp+HlrVxfFNNWIcCACqSBmPaUQzlDzW1gdGl1mA5WENUarbfKE0F0ZTGH9izjdKTbTkVJhiniaqFq8J/ya7j2xV+t6oma3VkcvCpipknG0anWTxe7Ou7laWqn4FZ/QXntKEsnJ8cFxuomY7cjncN4kqU0GXzMcHMFYzE7EI8wPtwE534S+YplhcqejHcII4eW1fB1IzN9q1q8UVO0rfj2gNrqL1TreWarWI96AbPFG3suuEMbRpNMgpyXjROPKTNIgGKnZC5Zsqctp77n24NTDtvQmzPX09fXbjOGFGwatGj0bsMEbAfmf4uONpaF0zfKjjvqJkqc70xHAhxirOTTNtIrRlXsWE4+cclRhWIlNoSxqSJGkwDmzsBVYXfQ8soOPIHjTj1TyrrgwzjteHxdDhqQ6O23Yf5U2res2H1coeN9Z7xzgTbehc07XMlmu907PMtmeBm3AtcCHX+n8RftwO2iZsBe2j7YHrnj/IQ+9eUsSbmlCnpgwnRQ+gxepJnifE7yNUtwKvFrpDW/51ieGUmzpCw77ddiKdFMXtdhTf3Yw1wzjoCOPu2cK4fS/Oc9P3V/AeEiSoOd2Mpoj0mxotzCAIO7mbhgvH+Ayp0XjPqca6UwGrw6nsczmVpYH+nIUMCSJLny8Z7hV46PnY97uALzzbso0zAHf3clGnA7h3ySjmaMDxK1aZzRugzY+DDrHTDTpwxwvD6GeF8PbYujpbtwMtOBdaV0PLcE4Lwqk6+F3jhR0L8EXxenqoeMw44ffIdj8uXB3uWIPLY4ZRQLLoD8B79dBg6ll8Kn9e6TfsLpDvBbALLoCWZQf9wBXJSRuuYelw7ZHZgRecC6+er4ldEhy7R7pVxMA2R+Obg2xrkAuO+KEU7R18e6C15+xm9ZTjiqjOk2X1MbOc47DMrqej52Olp02o+YDDSGhE/NsDZ+pLymXBVWPoBjfaiNFfG9rtMdMXCg1SEaNc7pK0fJ+giUTCEHyTSUKiTNi4fIizs35BC5w8yYRfTlw4W1DOaSoaJLLiAfm/olKAVjokN9GkPNmkyLfvPcg0CFWFkKylZA/qemYx5/KFiYkkAeZ+kFkj4tMsJEJaNvLFGcFcrHxIfEm7iDlz4UK0GKIsGC6Y+JQmW6Ykc+i4L1+X+GfxIpuINMUb5VXG1+t9r60Jb1/yMQPQ16771x1+WPchCugC79QHnvfyjIqiR91NcGPC6yvx/QtvHhV+ka3Ep5B1Lf7FnlgYpFk+3Sp6Uhq4+zkqBCNdbQh1tStb/2rrucT9q20cVTukLMLZsMBU/rI/d8Stwjwn5fPTYkhzTlLyu0wKenR0WN0O7MTXpbc6pLc+LL0o1m/WbX8JrV9PhI//AQ== \ No newline at end of file +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 \ No newline at end of file diff --git a/img/architecture/app-ddd.png b/img/architecture/app-ddd.png index 8b6390bc..2deaa2e2 100644 Binary files a/img/architecture/app-ddd.png and b/img/architecture/app-ddd.png differ diff --git a/src/application/ApplicationFactory.ts b/src/application/ApplicationFactory.ts new file mode 100644 index 00000000..c737a780 --- /dev/null +++ b/src/application/ApplicationFactory.ts @@ -0,0 +1,21 @@ +import { IApplication } from '@/domain/IApplication'; +import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy'; +import { IApplicationFactory } from './IApplicationFactory'; +import { parseApplication } from './Parser/ApplicationParser'; + +export type ApplicationGetter = () => IApplication; +const ApplicationGetter: ApplicationGetter = parseApplication; + +export class ApplicationFactory implements IApplicationFactory { + public static readonly Current: IApplicationFactory = new ApplicationFactory(ApplicationGetter); + private readonly getter: AsyncLazy; + protected constructor(costlyGetter: ApplicationGetter) { + if (!costlyGetter) { + throw new Error('undefined getter'); + } + this.getter = new AsyncLazy(() => Promise.resolve(costlyGetter())); + } + public getAppAsync(): Promise { + return this.getter.getValueAsync(); + } +} diff --git a/src/application/Context/ApplicationContextProvider.ts b/src/application/Context/ApplicationContextFactory.ts similarity index 66% rename from src/application/Context/ApplicationContextProvider.ts rename to src/application/Context/ApplicationContextFactory.ts index 1a8bb4ff..e3000ae7 100644 --- a/src/application/Context/ApplicationContextProvider.ts +++ b/src/application/Context/ApplicationContextFactory.ts @@ -4,15 +4,15 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; import { Environment } from '../Environment/Environment'; import { IApplication } from '@/domain/IApplication'; import { IEnvironment } from '../Environment/IEnvironment'; -import { parseApplication } from '../Parser/ApplicationParser'; +import { IApplicationFactory } from '../IApplicationFactory'; +import { ApplicationFactory } from '../ApplicationFactory'; -export type ApplicationParserType = () => IApplication; -const ApplicationParser: ApplicationParserType = parseApplication; - -export function buildContext( - parser = ApplicationParser, - environment = Environment.CurrentEnvironment): IApplicationContext { - const app = parser(); +export async function buildContextAsync( + factory: IApplicationFactory = ApplicationFactory.Current, + environment = Environment.CurrentEnvironment): Promise { + if (!factory) { throw new Error('undefined factory'); } + if (!environment) { throw new Error('undefined environment'); } + const app = await factory.getAppAsync(); const os = getInitialOs(app, environment); return new ApplicationContext(app, os); } diff --git a/src/application/IApplicationFactory.ts b/src/application/IApplicationFactory.ts new file mode 100644 index 00000000..a0cca695 --- /dev/null +++ b/src/application/IApplicationFactory.ts @@ -0,0 +1,5 @@ +import { IApplication } from '@/domain/IApplication'; + +export interface IApplicationFactory { + getAppAsync(): Promise; +} diff --git a/src/presentation/CodeButtons/MacOsInstructions.vue b/src/presentation/CodeButtons/MacOsInstructions.vue index 07f6fae9..17fd1360 100644 --- a/src/presentation/CodeButtons/MacOsInstructions.vue +++ b/src/presentation/CodeButtons/MacOsInstructions.vue @@ -80,29 +80,26 @@ diff --git a/src/presentation/CodeButtons/TheCodeButtons.vue b/src/presentation/CodeButtons/TheCodeButtons.vue index dfce4cfd..f358c114 100644 --- a/src/presentation/CodeButtons/TheCodeButtons.vue +++ b/src/presentation/CodeButtons/TheCodeButtons.vue @@ -65,9 +65,6 @@ export default class TheCodeButtons extends StatefulVue { } } - protected initialize(): void { - return; - } protected handleCollectionState(newState: ICategoryCollectionState): void { this.isMacOsCollection = newState.collection.os === OperatingSystem.macOS; this.fileName = buildFileName(newState.collection.scripting); diff --git a/src/presentation/Scripts/Cards/CardList.vue b/src/presentation/Scripts/Cards/CardList.vue index f39c45e4..5d23d563 100644 --- a/src/presentation/Scripts/Cards/CardList.vue +++ b/src/presentation/Scripts/Cards/CardList.vue @@ -22,7 +22,6 @@ 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: { @@ -45,9 +44,6 @@ export default class CardList extends StatefulVue { 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; diff --git a/src/presentation/Scripts/Cards/CardListItem.vue b/src/presentation/Scripts/Cards/CardListItem.vue index 01b2067a..76f72179 100644 --- a/src/presentation/Scripts/Cards/CardListItem.vue +++ b/src/presentation/Scripts/Cards/CardListItem.vue @@ -78,9 +78,7 @@ export default class CardListItem extends StatefulVue { this.cardTitle = category ? category.name : undefined; await this.updateSelectionIndicatorsAsync(value); } - protected initialize(): void { - return; - } + protected handleCollectionState(): void { return; } diff --git a/src/presentation/Scripts/ScriptsTree/ScriptsTree.vue b/src/presentation/Scripts/ScriptsTree/ScriptsTree.vue index ebf47963..7d53af09 100644 --- a/src/presentation/Scripts/ScriptsTree/ScriptsTree.vue +++ b/src/presentation/Scripts/ScriptsTree/ScriptsTree.vue @@ -73,9 +73,6 @@ export default class ScriptsTree extends StatefulVue { (category: ICategory) => node.id === getCategoryNodeId(category)); } - protected initialize(): void { - return; - } protected async handleCollectionState(newState: ICategoryCollectionState) { this.setCurrentFilter(newState.filter.currentFilter); if (!this.categoryId) { diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/Node/RevertToggle.vue b/src/presentation/Scripts/ScriptsTree/SelectableTree/Node/RevertToggle.vue index b558c338..e43d38f9 100644 --- a/src/presentation/Scripts/ScriptsTree/SelectableTree/Node/RevertToggle.vue +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/Node/RevertToggle.vue @@ -37,9 +37,6 @@ export default class RevertToggle extends StatefulVue { this.handler.selectWithRevertState(this.isReverted, context.state.selection); } - protected initialize(): void { - return; - } protected handleCollectionState(newState: ICategoryCollectionState): void { this.updateStatus(newState.selection.selectedScripts); this.events.unsubscribeAll(); diff --git a/src/presentation/Scripts/Selector/TheSelector.vue b/src/presentation/Scripts/Selector/TheSelector.vue index 5930e953..a7d46246 100644 --- a/src/presentation/Scripts/Selector/TheSelector.vue +++ b/src/presentation/Scripts/Selector/TheSelector.vue @@ -56,7 +56,6 @@ import { IScript } from '@/domain/IScript'; import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { RecommendationLevel } from '@/domain/RecommendationLevel'; import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; -import { IApplication } from '@/domain/IApplication'; enum SelectionState { Standard, @@ -82,9 +81,6 @@ export default class TheSelector extends StatefulVue { selectType(context.state, type); } - protected initialize(app: IApplication): void { - return; - } protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void { this.updateSelections(newState); newState.selection.changed.on(() => this.updateSelections(newState)); diff --git a/src/presentation/Scripts/TheOsChanger.vue b/src/presentation/Scripts/TheOsChanger.vue index 02d0998f..d034ca65 100644 --- a/src/presentation/Scripts/TheOsChanger.vue +++ b/src/presentation/Scripts/TheOsChanger.vue @@ -16,23 +16,24 @@ import { Component } from 'vue-property-decorator'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { StatefulVue } from '@/presentation/StatefulVue'; import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; -import { IApplication } from '@/domain/IApplication'; +import { ApplicationFactory } from '@/application/ApplicationFactory'; @Component export default class TheOsChanger extends StatefulVue { public allOses: Array<{ name: string, os: OperatingSystem }> = []; - public currentOs: OperatingSystem = undefined; + public currentOs: OperatingSystem = OperatingSystem.Unknown; + public async created() { + const app = await ApplicationFactory.Current.getAppAsync(); + this.allOses = app.getSupportedOsList() + .map((os) => ({ os, name: renderOsName(os) })); + } public async changeOsAsync(newOs: OperatingSystem) { const context = await this.getCurrentContextAsync(); context.changeContext(newOs); } - protected initialize(app: IApplication): void { - this.allOses = app.getSupportedOsList() - .map((os) => ({ os, name: renderOsName(os) })); - } - protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void { + protected handleCollectionState(newState: ICategoryCollectionState): void { this.currentOs = newState.os; this.$forceUpdate(); // v-bind:class is not updated otherwise } diff --git a/src/presentation/Scripts/TheScripts.vue b/src/presentation/Scripts/TheScripts.vue index 8d3475f7..8aaea94c 100644 --- a/src/presentation/Scripts/TheScripts.vue +++ b/src/presentation/Scripts/TheScripts.vue @@ -49,7 +49,7 @@ import { StatefulVue } from '@/presentation/StatefulVue'; import { Grouping } from './Grouping/Grouping'; import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; -import { IApplication } from '@/domain/IApplication'; +import { ApplicationFactory } from '@/application/ApplicationFactory'; /** Shows content of single category or many categories */ @Component({ @@ -78,6 +78,10 @@ export default class TheScripts extends StatefulVue { public isSearching = false; public searchHasMatches = false; + public async created() { + const app = await ApplicationFactory.Current.getAppAsync(); + this.repositoryUrl = app.info.repositoryWebUrl; + } public async clearSearchQueryAsync() { const context = await this.getCurrentContextAsync(); const filter = context.state.filter; @@ -87,9 +91,6 @@ export default class TheScripts extends StatefulVue { this.currentGrouping = group; } - protected initialize(app: IApplication): void { - this.repositoryUrl = app.info.repositoryWebUrl; - } protected handleCollectionState(newState: ICategoryCollectionState): void { this.events.unsubscribeAll(); this.subscribeState(newState); diff --git a/src/presentation/StatefulVue.ts b/src/presentation/StatefulVue.ts index c852881a..61db812c 100644 --- a/src/presentation/StatefulVue.ts +++ b/src/presentation/StatefulVue.ts @@ -1,17 +1,15 @@ 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 { buildContextAsync } from '@/application/Context/ApplicationContextFactory'; import { IApplicationContextChangedEvent } from '@/application/Context/IApplicationContext'; -import { IApplication } from '@/domain/IApplication'; import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; import { EventSubscriptionCollection } from '../infrastructure/Events/EventSubscriptionCollection'; // @ts-ignore because https://github.com/vuejs/vue-class-component/issues/91 @Component export abstract class StatefulVue extends Vue { - public static instance = new AsyncLazy( - () => Promise.resolve(buildContext())); + private static readonly instance = new AsyncLazy(() => buildContextAsync()); protected readonly events = new EventSubscriptionCollection(); @@ -20,7 +18,6 @@ export abstract class StatefulVue extends Vue { public async mounted() { const context = await this.getCurrentContextAsync(); this.ownEvents.register(context.contextChanged.on((event) => this.handleStateChangedEvent(event))); - this.initialize(context.app); this.handleCollectionState(context.state, undefined); } public destroyed() { @@ -28,7 +25,6 @@ export abstract class StatefulVue extends Vue { this.events.unsubscribeAll(); } - protected abstract initialize(app: IApplication): void; protected abstract handleCollectionState( newState: ICategoryCollectionState, oldState: ICategoryCollectionState | undefined): void; protected getCurrentContextAsync(): Promise { diff --git a/src/presentation/TheCodeArea.vue b/src/presentation/TheCodeArea.vue index d1b598fb..9495f401 100644 --- a/src/presentation/TheCodeArea.vue +++ b/src/presentation/TheCodeArea.vue @@ -26,9 +26,6 @@ export default class TheCodeArea extends StatefulVue { this.destroyEditor(); } - protected initialize(): void { - return; - } protected handleCollectionState(newState: ICategoryCollectionState): void { this.destroyEditor(); this.editor = initializeEditor(this.theme, this.editorId, newState.collection.scripting.language); diff --git a/src/presentation/TheFooter/DownloadUrlListItem.vue b/src/presentation/TheFooter/DownloadUrlListItem.vue index 646fe04e..fdc59f7f 100644 --- a/src/presentation/TheFooter/DownloadUrlListItem.vue +++ b/src/presentation/TheFooter/DownloadUrlListItem.vue @@ -9,15 +9,13 @@ diff --git a/src/presentation/TheFooter/TheFooter.vue b/src/presentation/TheFooter/TheFooter.vue index b540e38e..4f7cdfd2 100644 --- a/src/presentation/TheFooter/TheFooter.vue +++ b/src/presentation/TheFooter/TheFooter.vue @@ -47,22 +47,21 @@ diff --git a/src/presentation/TheHeader.vue b/src/presentation/TheHeader.vue index 3dc0a1f1..d8ad5ffb 100644 --- a/src/presentation/TheHeader.vue +++ b/src/presentation/TheHeader.vue @@ -1,27 +1,23 @@ diff --git a/src/presentation/TheSearchBar.vue b/src/presentation/TheSearchBar.vue index e6457517..efc5971e 100644 --- a/src/presentation/TheSearchBar.vue +++ b/src/presentation/TheSearchBar.vue @@ -36,9 +36,6 @@ export default class TheSearchBar extends StatefulVue { } } - protected initialize(): void { - return; - } protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState | undefined) { const totalScripts = newState.collection.totalScripts; this.searchPlaceHolder = `Search in ${totalScripts} scripts`; diff --git a/tests/unit/application/ApplicationFactory.spec.ts b/tests/unit/application/ApplicationFactory.spec.ts new file mode 100644 index 00000000..71ed4215 --- /dev/null +++ b/tests/unit/application/ApplicationFactory.spec.ts @@ -0,0 +1,60 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ApplicationFactory, ApplicationGetter } from '@/application/ApplicationFactory'; +import { ApplicationStub } from '../stubs/ApplicationStub'; + +describe('ApplicationFactory', () => { + describe('ctor', () => { + it('throws if getter is undefined', () => { + // arrange + const expectedError = 'undefined getter'; + const getter = undefined; + // act + const act = () => new SystemUnderTest(getter); + // assert + expect(act).to.throw(expectedError); + }); + }); + describe('getAppAsync', () => { + it('returns result from the getter', async () => { + // arrange + const expected = new ApplicationStub(); + const getter: ApplicationGetter = () => expected; + const sut = new SystemUnderTest(getter); + // act + const actual = await Promise.all( [ + sut.getAppAsync(), + sut.getAppAsync(), + sut.getAppAsync(), + sut.getAppAsync(), + ]); + // assert + expect(actual.every((value) => value === expected)); + }); + it('only executes getter once', async () => { + // arrange + let totalExecution = 0; + const expected = new ApplicationStub(); + const getter: ApplicationGetter = () => { + totalExecution++; + return expected; + }; + const sut = new SystemUnderTest(getter); + // act + await Promise.all( [ + sut.getAppAsync(), + sut.getAppAsync(), + sut.getAppAsync(), + sut.getAppAsync(), + ]); + // assert + expect(totalExecution).to.equal(1); + }); + }); +}); + +class SystemUnderTest extends ApplicationFactory { + public constructor(costlyGetter: ApplicationGetter) { + super(costlyGetter); + } +} diff --git a/tests/unit/application/Context/ApplicationContextFactory.spec.ts b/tests/unit/application/Context/ApplicationContextFactory.spec.ts new file mode 100644 index 00000000..0c5cd329 --- /dev/null +++ b/tests/unit/application/Context/ApplicationContextFactory.spec.ts @@ -0,0 +1,83 @@ +import 'mocha'; +import { expect } from 'chai'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { ICategoryCollection } from '@/domain/ICategoryCollection'; +import { CategoryCollectionStub } from '../../stubs/CategoryCollectionStub'; +import { EnvironmentStub } from '../../stubs/EnvironmentStub'; +import { ApplicationStub } from '../../stubs/ApplicationStub'; +import { buildContextAsync } from '@/application/Context/ApplicationContextFactory'; +import { IApplicationFactory } from '@/application/IApplicationFactory'; +import { IApplication } from '@/domain/IApplication'; + +describe('ApplicationContextFactory', () => { + describe('buildContextAsync', () => { + describe('factory', () => { + it('sets application from factory', async () => { + // arrange + const expected = new ApplicationStub().withCollection( + new CategoryCollectionStub().withOs(OperatingSystem.macOS)); + const factoryMock = mockFactoryWithApp(expected); + // act + const context = await buildContextAsync(factoryMock); + // assert + expect(expected).to.equal(context.app); + }); + }); + describe('environment', () => { + describe('sets initial OS as expected', () => { + it('returns currentOs if it is supported', async () => { + // arrange + const expected = OperatingSystem.Windows; + const environment = new EnvironmentStub().withOs(expected); + const collection = new CategoryCollectionStub().withOs(expected); + const factoryMock = mockFactoryWithCollection(collection); + // act + const context = await buildContextAsync(factoryMock, environment); + // assert + const actual = context.state.os; + expect(expected).to.equal(actual); + }); + it('fallbacks to other os if OS in environment is not supported', async () => { + // arrange + const expected = OperatingSystem.Windows; + const currentOs = OperatingSystem.macOS; + const environment = new EnvironmentStub().withOs(currentOs); + const collection = new CategoryCollectionStub().withOs(expected); + const factoryMock = mockFactoryWithCollection(collection); + // act + const context = await buildContextAsync(factoryMock, environment); + // assert + const actual = context.state.os; + expect(expected).to.equal(actual); + }); + it('fallbacks to most supported os if current os is not supported', async () => { + // 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 factoryMock = mockFactoryWithApp(app); + // act + const context = await buildContextAsync(factoryMock, environment); + // assert + const actual = context.state.os; + expect(expectedOs).to.equal(actual, `Expected: ${OperatingSystem[expectedOs]}, actual: ${OperatingSystem[actual]}`); + }); + }); + }); + }); +}); + +function mockFactoryWithCollection(result: ICategoryCollection): IApplicationFactory { + return mockFactoryWithApp(new ApplicationStub().withCollection(result)); +} + +function mockFactoryWithApp(app: IApplication): IApplicationFactory { + return { + getAppAsync: () => Promise.resolve(app), + }; +} diff --git a/tests/unit/application/Context/ApplicationContextProvider.spec.ts b/tests/unit/application/Context/ApplicationContextProvider.spec.ts deleted file mode 100644 index 1b9fddfa..00000000 --- a/tests/unit/application/Context/ApplicationContextProvider.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import 'mocha'; -import { expect } from 'chai'; -import { OperatingSystem } from '@/domain/OperatingSystem'; -import { ICategoryCollection } from '@/domain/ICategoryCollection'; -import { ApplicationParserType, buildContext } from '@/application/Context/ApplicationContextProvider'; -import { CategoryCollectionStub } from '../../stubs/CategoryCollectionStub'; -import { EnvironmentStub } from '../../stubs/EnvironmentStub'; -import { ApplicationStub } from '../../stubs/ApplicationStub'; - -describe('ApplicationContextProvider', () => { - describe('buildContext', () => { - it('sets application from parser', () => { - // arrange - const expected = new ApplicationStub().withCollection( - new CategoryCollectionStub().withOs(OperatingSystem.macOS)); - const parserMock: ApplicationParserType = () => expected; - // act - const context = buildContext(parserMock); - // assert - expect(expected).to.equal(context.app); - }); - describe('sets initial OS as expected', () => { - it('returns currentOs if it is supported', () => { - // arrange - const expected = OperatingSystem.Windows; - const environment = new EnvironmentStub().withOs(expected); - const parser = mockParser(new CategoryCollectionStub().withOs(expected)); - // act - const context = buildContext(parser, environment); - // assert - const actual = context.state.os; - expect(expected).to.equal(actual); - }); - it('fallbacks to other os if OS in environment is not supported', () => { - // arrange - const expected = OperatingSystem.Windows; - const currentOs = OperatingSystem.macOS; - const environment = new EnvironmentStub().withOs(currentOs); - const parser = mockParser(new CategoryCollectionStub().withOs(expected)); - // act - const context = buildContext(parser, environment); - // assert - const actual = context.state.os; - expect(expected).to.equal(actual); - }); - it('fallbacks to most supported os if current os is not supported', () => { - // 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]}`); - }); - }); - }); -}); - -function mockParser(result: ICategoryCollection): ApplicationParserType { - return () => new ApplicationStub().withCollection(result); -}