add support for different recommendation levels: strict and standard

This commit is contained in:
undergroundwires
2020-10-19 14:51:42 +01:00
parent 978bab0b81
commit 14be3017c5
20 changed files with 954 additions and 654 deletions

View File

@@ -28,7 +28,25 @@
### Extend scripts
- Create a [pull request](#Pull-Request-Process) for [application.yaml](./src/application/application.yaml)
- 🙏 For any new script, try to add `revertCode` that'll revert the changes caused by the script.
- 🙏 For any new script, please add `revertCode` and `docs` values if possible.
- Structure of `script` object:
- `name`: *`string`* (**required**)
- Name of the script
- E.g. `Disable targeted ads`
- `code`: *`string`* (**required**)
- Batch file commands that will be executed
- `docs`: *`string`* | `[ string, ... ]`
- Documentation URL or list of URLs for those who wants to learn more about the script
- E.g. `https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_telemetry`
- `revertCode`: `string`
- Code that'll undo the change done by `code` property.
- E.g. let's say `code` sets an environment variable as `setx POWERSHELL_TELEMETRY_OPTOUT 1`
- then `revertCode` should be doing `setx POWERSHELL_TELEMETRY_OPTOUT 0`
- `recommend`: `"standard"` | `"strict"` | `undefined` (default)
- If not defined then the script will not be recommended
- If defined it can be either
- `standard`: Will be recommended for general users
- `strict`: Will only be recommended with a warning
- See [typings](./src/application/application.yaml.d.ts) for documentation as code.
### Handle the state in presentation layer

View File

@@ -1,6 +1,7 @@
import { Script } from '@/domain/Script';
import { YamlScript } from 'js-yaml-loader!./application.yaml';
import { parseDocUrls } from './DocumentationParser';
import { RecommendationLevelNames, RecommendationLevel } from '@/domain/RecommendationLevel';
export function parseScript(yamlScript: YamlScript): Script {
if (!yamlScript) {
@@ -11,6 +12,21 @@ export function parseScript(yamlScript: YamlScript): Script {
/* code */ yamlScript.code,
/* revertCode */ yamlScript.revertCode,
/* docs */ parseDocUrls(yamlScript),
/* isRecommended */ yamlScript.recommend);
/* level */ getLevel(yamlScript.recommend));
return script;
}
function getLevel(level: string): RecommendationLevel | undefined {
if (!level) {
return undefined;
}
if (typeof level !== 'string') {
throw new Error(`level must be a string but it was ${typeof level}`);
}
const typedLevel = RecommendationLevelNames
.find((l) => l.toLowerCase() === level.toLowerCase());
if (!typedLevel) {
throw new Error(`unknown level: \"${level}\"`);
}
return RecommendationLevel[typedLevel as keyof typeof RecommendationLevel];
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ declare module 'js-yaml-loader!*' {
name: string;
code: string;
revertCode: string;
recommend: boolean;
recommend: string | undefined;
}
export interface YamlCategory extends YamlDocumentable {

View File

@@ -3,12 +3,13 @@ import { ICategory } from './ICategory';
import { IScript } from './IScript';
import { IApplication } from './IApplication';
import { IProjectInformation } from './IProjectInformation';
import { RecommendationLevel, RecommendationLevelNames, RecommendationLevels } from './RecommendationLevel';
export class Application implements IApplication {
public get totalScripts(): number { return this.flattened.allScripts.length; }
public get totalCategories(): number { return this.flattened.allCategories.length; }
public get totalScripts(): number { return this.queryable.allScripts.length; }
public get totalCategories(): number { return this.queryable.allCategories.length; }
private readonly flattened: IFlattenedApplication;
private readonly queryable: IQueryableApplication;
constructor(
public readonly info: IProjectInformation,
@@ -16,30 +17,36 @@ export class Application implements IApplication {
if (!info) {
throw new Error('info is undefined');
}
this.flattened = flatten(actions);
ensureValid(this.flattened);
ensureNoDuplicates(this.flattened.allCategories);
ensureNoDuplicates(this.flattened.allScripts);
this.queryable = makeQueryable(actions);
ensureValid(this.queryable);
ensureNoDuplicates(this.queryable.allCategories);
ensureNoDuplicates(this.queryable.allScripts);
}
public findCategory(categoryId: number): ICategory | undefined {
return this.flattened.allCategories.find((category) => category.id === categoryId);
return this.queryable.allCategories.find((category) => category.id === categoryId);
}
public getRecommendedScripts(): readonly IScript[] {
return this.flattened.allScripts.filter((script) => script.isRecommended);
public getScriptsByLevel(level: RecommendationLevel): readonly IScript[] {
if (isNaN(level)) {
throw new Error('undefined level');
}
if (!(level in RecommendationLevel)) {
throw new Error(`invalid level: ${level}`);
}
return this.queryable.scriptsByLevel.get(level);
}
public findScript(scriptId: string): IScript | undefined {
return this.flattened.allScripts.find((script) => script.id === scriptId);
return this.queryable.allScripts.find((script) => script.id === scriptId);
}
public getAllScripts(): IScript[] {
return this.flattened.allScripts;
return this.queryable.allScripts;
}
public getAllCategories(): ICategory[] {
return this.flattened.allCategories;
return this.queryable.allCategories;
}
}
@@ -61,55 +68,85 @@ function ensureNoDuplicates<TKey>(entities: ReadonlyArray<IEntity<TKey>>) {
}
}
interface IFlattenedApplication {
interface IQueryableApplication {
allCategories: ICategory[];
allScripts: IScript[];
scriptsByLevel: Map<RecommendationLevel, readonly IScript[]>;
}
function ensureValid(application: IFlattenedApplication) {
if (!application.allCategories || application.allCategories.length === 0) {
function ensureValid(application: IQueryableApplication) {
ensureValidCategories(application.allCategories);
ensureValidScripts(application.allScripts);
}
function ensureValidCategories(allCategories: readonly ICategory[]) {
if (!allCategories || allCategories.length === 0) {
throw new Error('Application must consist of at least one category');
}
if (!application.allScripts || application.allScripts.length === 0) {
}
function ensureValidScripts(allScripts: readonly IScript[]) {
if (!allScripts || allScripts.length === 0) {
throw new Error('Application must consist of at least one script');
}
if (application.allScripts.filter((script) => script.isRecommended).length === 0) {
throw new Error('Application must consist of at least one recommended script');
for (const level of RecommendationLevels) {
if (allScripts.every((script) => script.level !== level)) {
throw new Error(`none of the scripts are recommended as ${RecommendationLevel[level]}`);
}
}
}
function flattenApplication(categories: ReadonlyArray<ICategory>): [ICategory[], IScript[]] {
const allCategories = new Array<ICategory>();
const allScripts = new Array<IScript>();
flattenCategories(categories, allCategories, allScripts);
return [
allCategories,
allScripts,
];
}
function flattenCategories(
categories: ReadonlyArray<ICategory>,
flattened: IFlattenedApplication): IFlattenedApplication {
allCategories: ICategory[],
allScripts: IScript[]): IQueryableApplication {
if (!categories || categories.length === 0) {
return flattened;
return;
}
for (const category of categories) {
flattened.allCategories.push(category);
flattened = flattenScripts(category.scripts, flattened);
flattened = flattenCategories(category.subCategories, flattened);
allCategories.push(category);
flattenScripts(category.scripts, allScripts);
flattenCategories(category.subCategories, allCategories, allScripts);
}
return flattened;
}
function flattenScripts(
scripts: ReadonlyArray<IScript>,
flattened: IFlattenedApplication): IFlattenedApplication {
allScripts: IScript[]): IScript[] {
if (!scripts) {
return flattened;
return;
}
for (const script of scripts) {
flattened.allScripts.push(script);
allScripts.push(script);
}
return flattened;
}
function flatten(
categories: ReadonlyArray<ICategory>): IFlattenedApplication {
let flattened: IFlattenedApplication = {
allCategories: new Array<ICategory>(),
allScripts: new Array<IScript>(),
function makeQueryable(
actions: ReadonlyArray<ICategory>): IQueryableApplication {
const flattened = flattenApplication(actions);
return {
allCategories: flattened[0],
allScripts: flattened[1],
scriptsByLevel: groupByLevel(flattened[1]),
};
flattened = flattenCategories(categories, flattened);
return flattened;
}
function groupByLevel(allScripts: readonly IScript[]): Map<RecommendationLevel, readonly IScript[]> {
const map = new Map<RecommendationLevel, readonly IScript[]>();
for (const levelName of RecommendationLevelNames) {
const level = RecommendationLevel[levelName];
const scripts = allScripts.filter((script) => script.level !== undefined && script.level <= level);
map.set(level, scripts);
}
return map;
}

View File

@@ -1,6 +1,7 @@
import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
import { IProjectInformation } from './IProjectInformation';
import { RecommendationLevel } from './RecommendationLevel';
export interface IApplication {
readonly info: IProjectInformation;
@@ -8,7 +9,7 @@ export interface IApplication {
readonly totalCategories: number;
readonly actions: ReadonlyArray<ICategory>;
getRecommendedScripts(): ReadonlyArray<IScript>;
getScriptsByLevel(level: RecommendationLevel): ReadonlyArray<IScript>;
findCategory(categoryId: number): ICategory | undefined;
findScript(scriptId: string): IScript | undefined;
getAllScripts(): ReadonlyArray<IScript>;

View File

@@ -1,9 +1,10 @@
import { IEntity } from '../infrastructure/Entity/IEntity';
import { IDocumentable } from './IDocumentable';
import { RecommendationLevel } from './RecommendationLevel';
export interface IScript extends IEntity<string>, IDocumentable {
readonly name: string;
readonly isRecommended: boolean;
readonly level?: RecommendationLevel;
readonly documentationUrls: ReadonlyArray<string>;
readonly code: string;
readonly revertCode: string;

View File

@@ -0,0 +1,11 @@
export enum RecommendationLevel {
Standard = 0,
Strict = 1,
}
export const RecommendationLevelNames = Object
.values(RecommendationLevel)
.filter((level) => typeof level === 'string') as string[];
export const RecommendationLevels = RecommendationLevelNames
.map((level) => RecommendationLevel[level]) as RecommendationLevel[];

View File

@@ -1,5 +1,6 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import { IScript } from './IScript';
import { RecommendationLevel } from './RecommendationLevel';
export class Script extends BaseEntity<string> implements IScript {
constructor(
@@ -7,9 +8,10 @@ export class Script extends BaseEntity<string> implements IScript {
public readonly code: string,
public readonly revertCode: string,
public readonly documentationUrls: ReadonlyArray<string>,
public readonly isRecommended: boolean) {
public readonly level?: RecommendationLevel) {
super(name);
validateCode(name, code);
validateLevel(level);
if (revertCode) {
validateCode(name, revertCode);
if (code === revertCode) {
@@ -22,6 +24,12 @@ export class Script extends BaseEntity<string> implements IScript {
}
}
function validateLevel(level?: RecommendationLevel) {
if (level !== undefined && !(level in RecommendationLevel)) {
throw new Error(`invalid level: ${level}`);
}
}
function validateCode(name: string, code: string): void {
if (!code || code.length === 0) {
throw new Error(`Code of ${name} is empty or null`);

View File

@@ -5,23 +5,37 @@
<div class="part">
<SelectableOption
label="None"
:enabled="isNoneSelected"
@click="selectNoneAsync()">
</SelectableOption>
:enabled="this.currentSelection == SelectionState.None"
@click="selectAsync(SelectionState.None)"
v-tooltip="'Deselect all selected scripts. Good start to dive deeper into tweaks and select only what you want.'"
/>
</div>
<div class="part"> | </div>
<div class="part">
<SelectableOption
label="Recommended"
:enabled="isRecommendedSelected"
@click="selectRecommendedAsync()" />
label="Standard"
:enabled="this.currentSelection == SelectionState.Standard"
@click="selectAsync(SelectionState.Standard)"
v-tooltip="'🛡️ Balanced for privacy and functionality. OS and applications will function normally.'"
/>
</div>
<div class="part"> | </div>
<div class="part">
<SelectableOption
label="Strict"
:enabled="this.currentSelection == SelectionState.Strict"
@click="selectAsync(SelectionState.Strict)"
v-tooltip="'🚫 Stronger privacy, disables risky functions that may leak your data. Double check selected tweaks!'"
/>
</div>
<div class="part"> | </div>
<div class="part">
<SelectableOption
label="All"
:enabled="isAllSelected"
@click="selectAllAsync()" />
:enabled="this.currentSelection == SelectionState.All"
@click="selectAsync(SelectionState.All)"
v-tooltip="'🔒 Strongest privacy. Disables any functionality that may leak your data. ⚠️ Not recommended for inexperienced users'"
/>
</div>
</div>
</div>
@@ -31,19 +45,26 @@
import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import SelectableOption from './SelectableOption.vue';
import { IApplicationState } from '@/application/State/IApplicationState';
import { IApplicationState, IUserSelection } from '@/application/State/IApplicationState';
import { IScript } from '@/domain/IScript';
import { SelectedScript } from '../../../application/State/Selection/SelectedScript';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
enum SelectionState {
Standard,
Strict,
All,
None,
Custom,
}
@Component({
components: {
SelectableOption,
},
})
export default class TheSelector extends StatefulVue {
public isAllSelected = false;
public isNoneSelected = false;
public isRecommendedSelected = false;
public SelectionState = SelectionState;
public currentSelection = SelectionState.None;
public async mounted() {
const state = await this.getCurrentStateAsync();
@@ -52,43 +73,73 @@ export default class TheSelector extends StatefulVue {
this.updateSelections(state);
});
}
public async selectAllAsync(): Promise<void> {
if (this.isAllSelected) {
public async selectAsync(type: SelectionState): Promise<void> {
if (this.currentSelection === type) {
return;
}
const state = await this.getCurrentStateAsync();
state.selection.selectAll();
}
public async selectRecommendedAsync(): Promise<void> {
if (this.isRecommendedSelected) {
return;
}
const state = await this.getCurrentStateAsync();
state.selection.selectOnly(state.app.getRecommendedScripts());
}
public async selectNoneAsync(): Promise<void> {
if (this.isNoneSelected) {
return;
}
const state = await this.getCurrentStateAsync();
state.selection.deselectAll();
selectType(state, type);
}
private updateSelections(state: IApplicationState) {
this.isNoneSelected = state.selection.totalSelected === 0;
this.isAllSelected = state.selection.totalSelected === state.app.totalScripts;
this.isRecommendedSelected = this.areAllRecommended(state.app.getRecommendedScripts(),
state.selection.selectedScripts);
this.currentSelection = getCurrentSelectionState(state);
}
}
private areAllRecommended(scripts: ReadonlyArray<IScript>, other: ReadonlyArray<SelectedScript>): boolean {
other = other.filter((selected) => !(selected).revert);
return (scripts.length === other.length) &&
scripts.every((script) => other.some((selected) => selected.id === script.id));
interface ITypeSelector {
isSelected: (state: IApplicationState) => boolean;
select: (state: IApplicationState) => void;
}
const selectors = new Map<SelectionState, ITypeSelector>([
[SelectionState.None, {
select: (state) => state.selection.deselectAll(),
isSelected: (state) => state.selection.totalSelected === 0,
}],
[SelectionState.Standard, {
select: (state) => state.selection.selectOnly(state.app.getScriptsByLevel(RecommendationLevel.Standard)),
isSelected: (state) => hasAllSelectedLevelOf(RecommendationLevel.Standard, state),
}],
[SelectionState.Strict, {
select: (state) => state.selection.selectOnly(state.app.getScriptsByLevel(RecommendationLevel.Strict)),
isSelected: (state) => hasAllSelectedLevelOf(RecommendationLevel.Strict, state),
}],
[SelectionState.All, {
select: (state) => state.selection.selectAll(),
isSelected: (state) => state.selection.totalSelected === state.app.totalScripts,
}],
]);
function selectType(state: IApplicationState, type: SelectionState) {
const selector = selectors.get(type);
selector.select(state);
}
function getCurrentSelectionState(state: IApplicationState): SelectionState {
for (const [type, selector] of Array.from(selectors.entries())) {
if (selector.isSelected(state)) {
return type;
}
}
return SelectionState.Custom;
}
function hasAllSelectedLevelOf(level: RecommendationLevel, state: IApplicationState) {
const scripts = state.app.getScriptsByLevel(level);
const selectedScripts = state.selection.selectedScripts;
return areAllSelected(scripts, selectedScripts);
}
function areAllSelected(
expectedScripts: ReadonlyArray<IScript>,
selection: ReadonlyArray<SelectedScript>): boolean {
selection = selection.filter((selected) => !selected.revert);
if (expectedScripts.length < selection.length) {
return false;
}
const selectedScriptIds = selection.map((script) => script.id).sort();
const expectedScriptIds = expectedScripts.map((script) => script.id).sort();
return selectedScriptIds.every((id, index) => id === expectedScriptIds[index]);
}
</script>

View File

@@ -17,7 +17,7 @@ const NothingChosenCode =
.appendLine()
.appendCommentLine('-- 🤔 How to use')
.appendCommentLine(' 📙 Start by exploring different categories and choosing different tweaks.')
.appendCommentLine(' 📙 You can select "Recommended" on the top to select "safer" tweaks. Always double check!')
.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()

View File

@@ -4,6 +4,7 @@ import { parseApplication } from '@/application/Parser/ApplicationParser';
import 'mocha';
import { expect } from 'chai';
import { parseCategory } from '@/application/Parser/CategoryParser';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
describe('ApplicationParser', () => {
describe('parseApplication', () => {
@@ -86,19 +87,22 @@ describe('ApplicationParser', () => {
});
});
function getTestCategory(scriptName = 'testScript'): YamlCategory {
function getTestCategory(scriptPrefix = 'testScript'): YamlCategory {
return {
category: 'category name',
children: [ getTestScript(scriptName) ],
children: [
getTestScript(`${scriptPrefix}-standard`, RecommendationLevel.Standard),
getTestScript(`${scriptPrefix}-strict`, RecommendationLevel.Strict),
],
};
}
function getTestScript(scriptName: string): YamlScript {
function getTestScript(scriptName: string, level: RecommendationLevel = RecommendationLevel.Standard): YamlScript {
return {
name: scriptName,
code: 'script code',
revertCode: 'revert code',
recommend: true,
recommend: RecommendationLevel[level].toLowerCase(),
};
}

View File

@@ -4,6 +4,7 @@ import { parseCategory } from '@/application/Parser/CategoryParser';
import { YamlCategory, CategoryOrScript, YamlScript } from 'js-yaml-loader!./application.yaml';
import { parseScript } from '@/application/Parser/ScriptParser';
import { parseDocUrls } from '@/application/Parser/DocumentationParser';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
describe('CategoryParser', () => {
describe('parseCategory', () => {
@@ -104,6 +105,6 @@ function getTestScript(): YamlScript {
name: 'script name',
code: 'script code',
revertCode: 'revert code',
recommend: true,
recommend: RecommendationLevel[RecommendationLevel.Standard],
};
}

View File

@@ -3,26 +3,116 @@ import 'mocha';
import { expect } from 'chai';
import { parseScript } from '@/application/Parser/ScriptParser';
import { parseDocUrls } from '@/application/Parser/DocumentationParser';
import { RecommendationLevelNames, RecommendationLevel } from '@/domain/RecommendationLevel';
describe('ScriptParser', () => {
describe('parseScript', () => {
it('parseScript parses as expected', () => {
it('parses name as expected', () => {
// arrange
const expected: YamlScript = {
name: 'expected name',
code: 'expected code',
const script = getValidScript();
script.name = 'expected-name';
// act
const actual = parseScript(script);
// assert
expect(actual.name).to.equal(script.name);
});
it('parses code as expected', () => {
// arrange
const script = getValidScript();
script.code = 'expected-code';
// act
const actual = parseScript(script);
// assert
expect(actual.code).to.equal(script.code);
});
it('parses revertCode as expected', () => {
// arrange
const script = getValidScript();
script.code = 'expected-code';
// act
const actual = parseScript(script);
// assert
expect(actual.revertCode).to.equal(script.revertCode);
});
it('parses docs as expected', () => {
// arrange
const script = getValidScript();
script.docs = [ 'https://expected-doc1.com', 'https://expected-doc2.com' ];
const expected = parseDocUrls(script);
// act
const actual = parseScript(script);
// assert
expect(actual.documentationUrls).to.deep.equal(expected);
});
describe('level', () => {
it('accepts undefined level', () => {
const undefinedLevels: string[] = [ '', undefined ];
undefinedLevels.forEach((undefinedLevel) => {
// arrange
const script = getValidScript();
script.recommend = undefinedLevel;
// act
const actual = parseScript(script);
// assert
expect(actual.level).to.equal(undefined);
});
});
it('throws on unknown level', () => {
// arrange
const unknownLevel = 'boi';
const script = getValidScript();
script.recommend = unknownLevel;
// act
const act = () => parseScript(script);
// assert
expect(act).to.throw(`unknown level: "${unknownLevel}"`);
});
it('throws on non-string type', () => {
const nonStringTypes: any[] = [ 5, true ];
nonStringTypes.forEach((nonStringType) => {
// arrange
const script = getValidScript();
script.recommend = nonStringType;
// act
const act = () => parseScript(script);
// assert
expect(act).to.throw(`level must be a string but it was ${typeof nonStringType}`);
});
});
describe('parses level as expected', () => {
for (const levelText of RecommendationLevelNames) {
it(levelText, () => {
// arrange
const expectedLevel = RecommendationLevel[levelText];
const script = getValidScript();
script.recommend = levelText;
// act
const actual = parseScript(script);
// assert
expect(actual.level).to.equal(expectedLevel);
});
}
});
it('parses level case insensitive', () => {
// arrange
const script = getValidScript();
const expected = RecommendationLevel.Standard;
script.recommend = RecommendationLevel[expected].toUpperCase();
// act
const actual = parseScript(script);
// assert
expect(actual.level).to.equal(expected);
});
});
});
});
function getValidScript(): YamlScript {
return {
name: 'valid-name',
code: 'valid-code',
revertCode: 'expected revert code',
docs: ['hello.com'],
recommend: true,
recommend: RecommendationLevel[RecommendationLevel.Standard].toLowerCase(),
};
// act
const actual = parseScript(expected);
// assert
expect(actual.name).to.equal(expected.name);
expect(actual.code).to.equal(expected.code);
expect(actual.revertCode).to.equal(expected.revertCode);
expect(actual.documentationUrls).to.deep.equal(parseDocUrls(expected));
expect(actual.isRecommended).to.equal(expected.recommend);
});
});
});
}

View File

@@ -5,25 +5,79 @@ import 'mocha';
import { expect } from 'chai';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { IProjectInformation } from '@/domain/IProjectInformation';
import { RecommendationLevel, RecommendationLevels } from '@/domain/RecommendationLevel';
describe('Application', () => {
it('getRecommendedScripts returns as expected', () => {
describe('getScriptsByLevel', () => {
it('filters out scripts without levels', () => {
// arrange
const scriptsWithLevels = RecommendationLevels.map((level, index) =>
new ScriptStub(`Script${index}`).withLevel(level),
);
const toIgnore = new ScriptStub('script-to-ignore').withLevel(undefined);
for (const currentLevel of RecommendationLevels) {
const category = new CategoryStub(0)
.withScripts(...scriptsWithLevels)
.withScript(toIgnore);
const sut = new Application(createInformation(), [category]);
// act
const actual = sut.getScriptsByLevel(currentLevel);
// assert
expect(actual).to.not.include(toIgnore);
}
});
it(`${RecommendationLevel[RecommendationLevel.Standard]} filters ${RecommendationLevel[RecommendationLevel.Strict]}`, () => {
// arrange
const level = RecommendationLevel.Standard;
const expected = [
new ScriptStub('S3').withIsRecommended(true),
new ScriptStub('S4').withIsRecommended(true),
new ScriptStub('S1').withLevel(level),
new ScriptStub('S2').withLevel(level),
];
const sut = new Application(createInformation(), [
new CategoryStub(3).withScripts(expected[0], new ScriptStub('S1').withIsRecommended(false)),
new CategoryStub(2).withScripts(expected[1], new ScriptStub('S2').withIsRecommended(false)),
new CategoryStub(3).withScripts(...expected,
new ScriptStub('S3').withLevel(RecommendationLevel.Strict)),
]);
// act
const actual = sut.getRecommendedScripts();
const actual = sut.getScriptsByLevel(level);
// assert
expect(expected[0]).to.deep.equal(actual[0]);
expect(expected[1]).to.deep.equal(actual[1]);
expect(expected).to.deep.equal(actual);
});
describe('parameter validation', () => {
it(`${RecommendationLevel[RecommendationLevel.Strict]} includes ${RecommendationLevel[RecommendationLevel.Standard]}`, () => {
// arrange
const level = RecommendationLevel.Strict;
const expected = [
new ScriptStub('S1').withLevel(RecommendationLevel.Standard),
new ScriptStub('S2').withLevel(RecommendationLevel.Strict),
];
const sut = new Application(createInformation(), [
new CategoryStub(3).withScripts(...expected),
]);
// act
const actual = sut.getScriptsByLevel(level);
// assert
expect(expected).to.deep.equal(actual);
});
it('throws when level is undefined', () => {
// arrange
const sut = new Application(createInformation(), [ getCategoryForValidApplication() ]);
// act
const act = () => sut.getScriptsByLevel(undefined);
// assert
expect(act).to.throw('undefined level');
});
it('throws when level is out of range', () => {
// arrange
const invalidValue = 66;
const sut = new Application(createInformation(), [
getCategoryForValidApplication(),
]);
// act
const act = () => sut.getScriptsByLevel(invalidValue);
// assert
expect(act).to.throw(`invalid level: ${invalidValue}`);
});
});
describe('ctor', () => {
it('cannot construct without categories', () => {
// arrange
const categories = [];
@@ -43,20 +97,24 @@ describe('Application', () => {
// assert
expect(construct).to.throw('Application must consist of at least one script');
});
it('cannot construct without any recommended scripts', () => {
describe('cannot construct without any recommended scripts', () => {
for (const missingLevel of RecommendationLevels) {
// arrange
const categories = [
new CategoryStub(3).withScripts(new ScriptStub('S1').withIsRecommended(false)),
new CategoryStub(2).withScripts(new ScriptStub('S2').withIsRecommended(false)),
];
const expectedError = `none of the scripts are recommended as ${RecommendationLevel[missingLevel]}`;
const otherLevels = RecommendationLevels.filter((level) => level !== missingLevel);
const categories = otherLevels.map((level, index) =>
new CategoryStub(index).withScript(new ScriptStub(`Script${index}`).withLevel(level)),
);
// act
function construct() { return new Application(createInformation(), categories); }
const construct = () => new Application(createInformation(), categories);
// assert
expect(construct).to.throw('Application must consist of at least one recommended script');
expect(construct).to.throw(expectedError);
}
});
it('cannot construct without information', () => {
// arrange
const categories = [new CategoryStub(1).withScripts(new ScriptStub('S1').withIsRecommended(true))];
const categories = [ new CategoryStub(1).withScripts(
new ScriptStub('S1').withLevel(RecommendationLevel.Standard))];
const information = undefined;
// act
function construct() { return new Application(information, categories); }
@@ -64,22 +122,29 @@ describe('Application', () => {
expect(construct).to.throw('info is undefined');
});
});
it('totalScripts counts right', () => {
describe('totalScripts', () => {
it('returns total of initial scripts', () => {
// arrange
const categories = [
new CategoryStub(1).withScripts(new ScriptStub('S1').withIsRecommended(true)),
new CategoryStub(2).withScripts(new ScriptStub('S2'), new ScriptStub('S3')),
new CategoryStub(3).withCategories(new CategoryStub(4).withScripts(new ScriptStub('S4'))),
new CategoryStub(1).withScripts(
new ScriptStub('S1').withLevel(RecommendationLevel.Standard)),
new CategoryStub(2).withScripts(
new ScriptStub('S2'),
new ScriptStub('S3').withLevel(RecommendationLevel.Strict)),
new CategoryStub(3).withCategories(
new CategoryStub(4).withScripts(new ScriptStub('S4'))),
];
// act
const sut = new Application(createInformation(), categories);
// assert
expect(sut.totalScripts).to.equal(4);
});
it('totalCategories counts right', () => {
});
describe('totalCategories', () => {
it('returns total of initial categories', () => {
// arrange
const categories = [
new CategoryStub(1).withScripts(new ScriptStub('S1').withIsRecommended(true)),
new CategoryStub(1).withScripts(new ScriptStub('S1').withLevel(RecommendationLevel.Strict)),
new CategoryStub(2).withScripts(new ScriptStub('S2'), new ScriptStub('S3')),
new CategoryStub(3).withCategories(new CategoryStub(4).withScripts(new ScriptStub('S4'))),
];
@@ -88,17 +153,25 @@ describe('Application', () => {
// assert
expect(sut.totalCategories).to.equal(4);
});
it('sets information as expected', () => {
});
describe('info', () => {
it('returns initial information', () => {
// arrange
const expected = createInformation();
// act
const sut = new Application(
expected,
[new CategoryStub(1).withScripts(new ScriptStub('S1').withIsRecommended(true))]);
expected, [ getCategoryForValidApplication() ]);
// assert
expect(sut.info).to.deep.equal(expected);
});
});
});
function getCategoryForValidApplication() {
return new CategoryStub(1).withScripts(
new ScriptStub('S1').withLevel(RecommendationLevel.Standard),
new ScriptStub('S2').withLevel(RecommendationLevel.Strict));
}
function createInformation(): IProjectInformation {
return new ProjectInformation('name', 'repo', '0.1.0', 'homepage');

View File

@@ -0,0 +1,17 @@
import 'mocha';
import { expect } from 'chai';
import { RecommendationLevelNames, RecommendationLevel } from '@/domain/RecommendationLevel';
describe('RecommendationLevel', () => {
describe('RecommendationLevelNames', () => {
// arrange
const expected = [
RecommendationLevel[RecommendationLevel.Strict],
RecommendationLevel[RecommendationLevel.Standard],
];
// act
const actual = RecommendationLevelNames;
// assert
expect(actual).to.have.deep.members(expected);
});
});

View File

@@ -1,6 +1,7 @@
import 'mocha';
import { expect } from 'chai';
import { Script } from '@/domain/Script';
import { RecommendationLevelNames, RecommendationLevel } from '@/domain/RecommendationLevel';
describe('Script', () => {
describe('ctor', () => {
@@ -13,6 +14,11 @@ describe('Script', () => {
const code = 'duplicate\n\n\ntest\nduplicate';
expect(() => createWithCode(code)).to.throw();
});
it('sets as expected', () => {
const expected = 'expected-revert';
const sut = createWithCode(expected);
expect(sut.code).to.equal(expected);
});
});
describe('revertCode', () => {
it('cannot construct with duplicate lines', () => {
@@ -27,6 +33,11 @@ describe('Script', () => {
const code = 'REM';
expect(() => createWithCode(code, code)).to.throw();
});
it('sets as expected', () => {
const expected = 'expected-revert';
const sut = createWithCode('abc', expected);
expect(sut.revertCode).to.equal(expected);
});
});
describe('canRevert', () => {
it('returns false without revert code', () => {
@@ -38,9 +49,28 @@ describe('Script', () => {
expect(sut.canRevert()).to.equal(true);
});
});
describe('level', () => {
it('cannot construct with invalid wrong value', () => {
expect(() => createWithLevel(55)).to.throw('invalid level');
});
it('sets undefined as expected', () => {
const sut = createWithLevel(undefined);
expect(sut.level).to.equal(undefined);
});
it('sets as expected', () => {
for (const expected of RecommendationLevelNames) {
const sut = createWithLevel(RecommendationLevel[expected]);
const actual = RecommendationLevel[sut.level];
expect(actual).to.equal(expected);
}
});
});
});
});
function createWithCode(code: string, revertCode?: string): Script {
return new Script('name', code, revertCode, [], false);
return new Script('name', code, revertCode, [], RecommendationLevel.Standard);
}
function createWithLevel(level: RecommendationLevel): Script {
return new Script('name', 'code', 'revertCode', [], level);
}

View File

@@ -4,6 +4,7 @@ import { ILiquorTreeNode } from 'liquor-tree';
import { NodeType } from '@/presentation/Scripts/ScriptsTree/SelectableTree/Node/INode';
import { getNewState } from '@/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater';
describe('NodeStateUpdater', () => {
describe('getNewState', () => {
describe('checked', () => {
describe('script node', () => {
@@ -188,7 +189,6 @@ describe('getNewState', () => {
});
});
});
function getScriptNode(scriptNodeId: string = 'script'): ILiquorTreeNode {
return {
id: scriptNodeId,
@@ -200,3 +200,4 @@ function getScriptNode(scriptNodeId: string = 'script'): ILiquorTreeNode {
children: [],
};
}
});

View File

@@ -15,8 +15,8 @@ export class ApplicationStub implements IApplication {
return this.getAllCategories().find(
(category) => category.id === categoryId);
}
public getRecommendedScripts(): readonly IScript[] {
throw new Error('Method not implemented: getRecommendedScripts');
public getScriptsByLevel(): readonly IScript[] {
throw new Error('Method not implemented: getScriptsByLevel');
}
public findScript(scriptId: string): IScript {
return this.getAllScripts().find((script) => scriptId === script.id);

View File

@@ -1,32 +1,37 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import { IScript } from '@/domain/IScript';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
export class ScriptStub extends BaseEntity<string> implements IScript {
public name = `name${this.id}`;
public code = `REM code${this.id}`;
public revertCode = `REM revertCode${this.id}`;
public readonly documentationUrls = new Array<string>();
public isRecommended = true;
public level = RecommendationLevel.Standard;
constructor(public readonly id: string) {
super(id);
}
public canRevert(): boolean {
return Boolean(this.revertCode);
}
public withIsRecommended(value: boolean): ScriptStub {
this.isRecommended = value;
public withLevel(value: RecommendationLevel): ScriptStub {
this.level = value;
return this;
}
public withCode(value: string): ScriptStub {
this.code = value;
return this;
}
public withName(name: string): ScriptStub {
this.name = name;
return this;
}
public withRevertCode(revertCode: string): ScriptStub {
this.revertCode = revertCode;
return this;