refactor state handling to make application available independent of the state

This commit is contained in:
undergroundwires
2021-02-07 12:24:15 +01:00
parent 67b2d1c11c
commit df273f7f63
28 changed files with 275 additions and 198 deletions

View File

@@ -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.

View File

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

22
docs/application.md Normal file
View File

@@ -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.

24
docs/presentation.md Normal file
View File

@@ -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.

View File

@@ -1 +1 @@
<mxfile host="www.draw.io" modified="2019-12-27T03:04:27.829Z" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36" etag="O-1eaon4mqUmgvki0auB" version="12.4.3" pages="1"><diagram id="rhL8jzEM8kVVyiS98U7u" name="Page-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==</diagram></mxfile>
<mxfile host="Electron" modified="2021-01-31T12:32:01.751Z" agent="5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/14.1.8 Chrome/87.0.4280.88 Electron/11.1.1 Safari/537.36" etag="OTbSPW1ZOLwiPL6mt-j9" version="14.1.8" type="device"><diagram id="rhL8jzEM8kVVyiS98U7u" name="Page-1">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</diagram></mxfile>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -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<IApplication>;
protected constructor(costlyGetter: ApplicationGetter) {
if (!costlyGetter) {
throw new Error('undefined getter');
}
this.getter = new AsyncLazy<IApplication>(() => Promise.resolve(costlyGetter()));
}
public getAppAsync(): Promise<IApplication> {
return this.getter.getValueAsync();
}
}

View File

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

View File

@@ -0,0 +1,5 @@
import { IApplication } from '@/domain/IApplication';
export interface IApplicationFactory {
getAppAsync(): Promise<IApplication>;
}

View File

@@ -80,29 +80,26 @@
</template>
<script lang="ts">
import { Component, Prop } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { Component, Prop, Vue } from 'vue-property-decorator';
import Code from './Code.vue';
import { IApplication } from '@/domain/IApplication';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ApplicationFactory } from '@/application/ApplicationFactory';
@Component({
components: {
Code,
},
})
export default class MacOsInstructions extends StatefulVue {
export default class MacOsInstructions extends Vue {
@Prop() public fileName: string;
public appName = '';
public macOsDownloadUrl = '';
protected initialize(app: IApplication): void {
public async created() {
const app = await ApplicationFactory.Current.getAppAsync();
this.appName = app.info.name;
this.macOsDownloadUrl = app.info.getDownloadUrl(OperatingSystem.macOS);
}
protected handleCollectionState(): void {
return;
}
}
</script>

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<IApplicationContext>(
() => Promise.resolve(buildContext()));
private static readonly instance = new AsyncLazy<IApplicationContext>(() => 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<IApplicationContext> {

View File

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

View File

@@ -9,15 +9,13 @@
</template>
<script lang="ts">
import { Component, Prop, Watch } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { Component, Prop, Watch, Vue } from 'vue-property-decorator';
import { Environment } from '@/application/Environment/Environment';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IApplication } from '@/domain/IApplication';
import { ApplicationFactory } from '@/application/ApplicationFactory';
@Component
export default class DownloadUrlListItem extends StatefulVue {
export default class DownloadUrlListItem extends Vue {
@Prop() public operatingSystem!: OperatingSystem;
public downloadUrl: string = '';
@@ -38,16 +36,9 @@ 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<string> {
const context = await this.getCurrentContextAsync();
return context.app.info.getDownloadUrl(os);
const context = await ApplicationFactory.Current.getAppAsync();
return context.info.getDownloadUrl(os);
}
}

View File

@@ -31,25 +31,27 @@
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { Component, Vue } from 'vue-property-decorator';
import { Environment } from '@/application/Environment/Environment';
import { ApplicationFactory } from '@/application/ApplicationFactory';
import { IApplication } from '@/domain/IApplication';
@Component
export default class PrivacyPolicy extends StatefulVue {
export default class PrivacyPolicy extends Vue {
public repositoryUrl: string = '';
public feedbackUrl: string = '';
public isDesktop = Environment.CurrentEnvironment.isDesktop;
protected initialize(app: IApplication): void {
public async created() {
const app = await ApplicationFactory.Current.getAppAsync();
this.initialize(app);
}
private initialize(app: IApplication) {
const info = app.info;
this.repositoryUrl = info.repositoryWebUrl;
this.feedbackUrl = info.feedbackUrl;
}
protected handleCollectionState(): void {
return;
}
}
</script>

View File

@@ -47,22 +47,21 @@
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { Component, Vue } from 'vue-property-decorator';
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';
import { ApplicationFactory } from '@/application/ApplicationFactory';
@Component({
components: {
PrivacyPolicy, DownloadUrlList,
},
})
export default class TheFooter extends StatefulVue {
export default class TheFooter extends Vue {
public readonly modalName = 'privacy-policy';
public readonly isDesktop: boolean;
public readonly isDesktop = Environment.CurrentEnvironment.isDesktop;
public version: string = '';
public repositoryUrl: string = '';
@@ -70,12 +69,12 @@ export default class TheFooter extends StatefulVue {
public feedbackUrl: string = '';
public homepageUrl: string = '';
constructor() {
super();
this.isDesktop = Environment.CurrentEnvironment.isDesktop;
public async created() {
const app = await ApplicationFactory.Current.getAppAsync();
this.initialize(app);
}
protected initialize(app: IApplication): void {
private initialize(app: IApplication) {
const info = app.info;
this.version = info.version;
this.homepageUrl = info.homepage;
@@ -83,10 +82,6 @@ export default class TheFooter extends StatefulVue {
this.releaseUrl = info.releaseUrl;
this.feedbackUrl = info.feedbackUrl;
}
protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void {
return;
}
}
</script>

View File

@@ -1,27 +1,23 @@
<template>
<div id="container">
<h1 class="child title" >{{ title }}</h1>
<h2 class="child subtitle">Enforce privacy & security on Windows and macOS</h2>
<h2 class="child subtitle">Enforce privacy &amp; security on Windows and macOS</h2>
</div>
</template>
<script lang="ts">
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IApplication } from '@/domain/IApplication';
import { Component } from 'vue-property-decorator';
import { StatefulVue } from './StatefulVue';
import { ApplicationFactory } from '@/application/ApplicationFactory';
import { Component, Vue } from 'vue-property-decorator';
@Component
export default class TheHeader extends StatefulVue {
export default class TheHeader extends Vue {
public title = '';
public subtitle = '';
protected initialize(app: IApplication): void {
public async created() {
const app = await ApplicationFactory.Current.getAppAsync();
this.title = app.info.name;
}
protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void {
return;
}
}
</script>

View File

@@ -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`;

View File

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

View File

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

View File

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