refactor application.yaml to become an os definition #40

This commit is contained in:
undergroundwires
2020-09-08 21:47:18 +01:00
parent e4b6cdfb18
commit f7557bcc0f
62 changed files with 1926 additions and 573 deletions

View File

@@ -31,7 +31,7 @@
- **Stateless**, extends `Vue`
- **Stateful**, extends [`StatefulVue`](./src/presentation/StatefulVue.ts)
- The source of truth for the state lies in application layer (`./src/application/`) and must be updated from the views if they're mutating the state
- They mutate or/and reacts to changes in [application state](src/application/State/ApplicationState.ts).
- They mutate or/and react to changes in [application state](src/application/State/ApplicationState.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

View File

@@ -10,16 +10,23 @@
### `Application`
- Application file simply defines different categories and their scripts in a tree structure.
- Application file simply defines:
- different categories and their scripts in a tree structure
- OS specific details
- Application file also allows defining common [function](#function)s to be used throughout the application if you'd like different scripts to share same code.
#### `Application` syntax
- `os:` *`string`* (**required**)
- Operating system that the application file is written for.
- 📖 See [OperatingSystem.ts](./../src/domain/OperatingSystem.ts) enumeration for allowed values.
- `actions: [` ***[`Category`](#Category)*** `, ... ]` **(required)**
- Each [category](#category) is rendered as different cards in card presentation.
- ❗ Application must consist of at least one category.
- `functions: [` ***[`Function`](#Function)*** `, ... ]`
- Functions are optionally defined to re-use the same code throughout different scripts.
- `scripting:` ***[`ScriptingDefinition`](#ScriptingDefinition)*** **(required)**
- Defines the scripting language that the code of other action uses.
### `Category`
@@ -137,3 +144,18 @@ It would print "Hello world" if it's called in a [script](#script) as following:
- 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`
### `ScriptingDefinition`
- Defines global properties for scripting that's used throughout the application file.
#### `ScriptingDefinition` syntax
- `language:` *`string`* (**required**)
- 📖 See [ScriptingLanguage.ts](./../src/domain/ScriptingLanguage.ts) enumeration for allowed values.
- `startCode:` *`string`* (**required**)
- Code that'll be inserted on top of user created script.
- Global variables such as `$homepage`, `$version`, `$date` can be used using [parameter substitution](#parameter-substitution) code syntax such as `Welcome to {{ $homepage }}!`
- `endCode:` *`string`* (**required**)
- Code that'll be inserted at the end of user created script.
- Global variables such as `$homepage`, `$version`, `$date` can be used using [parameter substitution](#parameter-substitution) code syntax such as `Welcome to {{ $homepage }}!`

View File

@@ -0,0 +1,43 @@
// Because we cannot do "T extends enum" 😞 https://github.com/microsoft/TypeScript/issues/30611
type EnumType = number | string;
type EnumVariable<T extends EnumType, TEnumValue extends EnumType> = { [key in T]: TEnumValue };
export interface IEnumParser<TEnum> {
parseEnum(value: string, propertyName: string): TEnum;
}
export function createEnumParser<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>): IEnumParser<TEnumValue> {
return {
parseEnum: (value, propertyName) => parseEnumValue(value, propertyName, enumVariable),
};
}
function parseEnumValue<T extends EnumType, TEnumValue extends EnumType>(
value: string,
enumName: string,
enumVariable: EnumVariable<T, TEnumValue>): TEnumValue {
if (!value) {
throw new Error(`undefined ${enumName}`);
}
if (typeof value !== 'string') {
throw new Error(`unexpected type of ${enumName}: "${typeof value}"`);
}
const casedValue = getEnumNames(enumVariable)
.find((enumValue) => enumValue.toLowerCase() === value.toLowerCase());
if (!casedValue) {
throw new Error(`unknown ${enumName}: "${value}"`);
}
return enumVariable[casedValue as keyof typeof enumVariable];
}
export function getEnumNames<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>): string[] {
return Object
.values(enumVariable)
.filter((enumMember) => typeof enumMember === 'string') as string[];
}
export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>): TEnumValue[] {
return getEnumNames(enumVariable)
.map((level) => enumVariable[level]) as TEnumValue[];
}

View File

@@ -1,14 +1,18 @@
import { Category } from '@/domain/Category';
import { Application } from '@/domain/Application';
import { IApplication } from '@/domain/IApplication';
import { IProjectInformation } from '@/domain/IProjectInformation';
import { ApplicationYaml } from 'js-yaml-loader!./../application.yaml';
import { YamlApplication } from 'js-yaml-loader!./../application.yaml';
import { parseCategory } from './CategoryParser';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { parseProjectInformation } from './ProjectInformationParser';
import { ScriptCompiler } from './Compiler/ScriptCompiler';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { parseScriptingDefinition } from './ScriptingDefinitionParser';
import { createEnumParser } from '../Common/Enum';
export function parseApplication(content: ApplicationYaml, env: NodeJS.ProcessEnv = process.env): IApplication {
export function parseApplication(
content: YamlApplication,
env: NodeJS.ProcessEnv = process.env,
osParser = createEnumParser(OperatingSystem)): IApplication {
validate(content);
const compiler = new ScriptCompiler(content.functions);
const categories = new Array<Category>();
@@ -16,23 +20,18 @@ export function parseApplication(content: ApplicationYaml, env: NodeJS.ProcessEn
const category = parseCategory(action, compiler);
categories.push(category);
}
const info = readAppInformation(env);
const os = osParser.parseEnum(content.os, 'os');
const info = parseProjectInformation(env);
const scripting = parseScriptingDefinition(content.scripting, info);
const app = new Application(
os,
info,
categories);
categories,
scripting);
return app;
}
function readAppInformation(environment: NodeJS.ProcessEnv): IProjectInformation {
return new ProjectInformation(
environment.VUE_APP_NAME,
environment.VUE_APP_VERSION,
environment.VUE_APP_REPOSITORY_URL,
environment.VUE_APP_HOMEPAGE_URL,
);
}
function validate(content: ApplicationYaml): void {
function validate(content: YamlApplication): void {
if (!content) {
throw new Error('application is null or undefined');
}

View File

@@ -0,0 +1,73 @@
export interface IILCode {
compile(): string;
getUniqueParameterNames(): string[];
substituteParameter(parameterName: string, parameterValue: string): IILCode;
}
export function generateIlCode(rawText: string): IILCode {
const ilCode = generateIl(rawText);
return new ILCode(ilCode);
}
class ILCode implements IILCode {
private readonly ilCode: string;
constructor(ilCode: string) {
this.ilCode = ilCode;
}
public substituteParameter(parameterName: string, parameterValue: string): IILCode {
const newCode = substituteParameter(this.ilCode, parameterName, parameterValue);
return new ILCode(newCode);
}
public getUniqueParameterNames(): string[] {
return getUniqueParameterNames(this.ilCode);
}
public compile(): string {
ensureNoExpressionLeft(this.ilCode);
return this.ilCode;
}
}
// Trim each expression and put them inside "{{exp|}}" e.g. "{{ $hello }}" becomes "{{exp|$hello}}"
function generateIl(rawText: string): string {
return rawText.replace(/\{\{([\s]*[^;\s\{]+[\s]*)\}\}/g, (_, match) => {
return `\{\{exp|${match.trim()}\}\}`;
});
}
// finds all "{{exp|..}} left"
function ensureNoExpressionLeft(ilCode: string) {
const allSubstitutions = ilCode.matchAll(/\{\{exp\|(.*?)\}\}/g);
const allMatches = Array.from(allSubstitutions, (match) => match[1]);
const uniqueExpressions = getDistinctValues(allMatches);
if (uniqueExpressions.length > 0) {
throw new Error(`unknown expression: ${printList(uniqueExpressions)}`);
}
}
// Parses all distinct usages of {{exp|$parameterName}}
function getUniqueParameterNames(ilCode: string) {
const allSubstitutions = ilCode.matchAll(/\{\{exp\|\$([^;\s\{]+[\s]*)\}\}/g);
const allParameters = Array.from(allSubstitutions, (match) => match[1]);
const uniqueParameterNames = getDistinctValues(allParameters);
return uniqueParameterNames;
}
// substitutes {{exp|$parameterName}} to value of the parameter
function substituteParameter(ilCode: string, parameterName: string, parameterValue: string) {
const pattern = `{{exp|$${parameterName}}}`;
return ilCode.split(pattern).join(parameterValue); // as .replaceAll() is not yet supported by TS
}
function getDistinctValues(values: readonly string[]): string[] {
return values.filter((value, index, self) => {
return self.indexOf(value) === index;
});
}
function printList(list: readonly string[]): string {
return `"${list.join('","')}"`;
}

View File

@@ -1,3 +1,4 @@
import { generateIlCode, IILCode } from './ILCode';
import { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode';
import { YamlScript, YamlFunction, FunctionCall, ScriptFunctionCall, FunctionCallParameters } from 'js-yaml-loader!./application.yaml';
@@ -125,20 +126,22 @@ function compileCode(func: YamlFunction, parameters: FunctionCallParameters): IC
}
function compileExpressions(code: string, parameters: FunctionCallParameters): string {
let intermediateCode = compileToIL(code);
let intermediateCode = generateIlCode(code);
intermediateCode = substituteParameters(intermediateCode, parameters);
ensureNoExpressionLeft(intermediateCode);
return intermediateCode;
return intermediateCode.compile();
}
function substituteParameters(intermediateCode: string, parameters: FunctionCallParameters): string {
const parameterNames = getUniqueParameterNamesFromIL(intermediateCode);
function substituteParameters(intermediateCode: IILCode, parameters: FunctionCallParameters): IILCode {
const parameterNames = intermediateCode.getUniqueParameterNames();
if (parameterNames.length && !parameters) {
throw new Error(`no parameters defined, expected: ${printList(parameterNames)}`);
}
for (const parameterName of parameterNames) {
const parameterValue = parameters[parameterName];
intermediateCode = substituteParameter(intermediateCode, parameterName, parameterValue);
if (!parameterValue) {
throw Error(`parameter value is not provided for "${parameterName}" in function call`);
}
intermediateCode = intermediateCode.substituteParameter(parameterName, parameterValue);
}
return intermediateCode;
}
@@ -158,43 +161,3 @@ function getCallSequence(call: ScriptFunctionCall): FunctionCall[] {
}
return [ call as FunctionCall ];
}
function getDistinctValues(values: readonly string[]): string[] {
return values.filter((value, index, self) => {
return self.indexOf(value) === index;
});
}
// Trim each expression and put them inside "{{exp|}}" e.g. "{{ $hello }}" becomes "{{exp|$hello}}"
function compileToIL(code: string) {
return code.replace(/\{\{([\s]*[^;\s\{]+[\s]*)\}\}/g, (_, match) => {
return `\{\{exp|${match.trim()}\}\}`;
});
}
// Parses all distinct usages of {{exp|$parameterName}}
function getUniqueParameterNamesFromIL(ilCode: string) {
const allSubstitutions = ilCode.matchAll(/\{\{exp\|\$([^;\s\{]+[\s]*)\}\}/g);
const allParameters = Array.from(allSubstitutions, (match) => match[1]);
const uniqueParameterNames = getDistinctValues(allParameters);
return uniqueParameterNames;
}
// substitutes {{exp|$parameterName}} to value of the parameter
function substituteParameter(ilCode: string, parameterName: string, parameterValue: string) {
if (!parameterValue) {
throw Error(`parameter value is not provided for "${parameterName}" in function call`);
}
const pattern = `{{exp|$${parameterName}}}`;
return ilCode.split(pattern).join(parameterValue); // as .replaceAll() is not yet supported by TS
}
// finds all "{{exp|..}} left"
function ensureNoExpressionLeft(ilCode: string) {
const allSubstitutions = ilCode.matchAll(/\{\{exp\|(.*?)\}\}/g);
const allMatches = Array.from(allSubstitutions, (match) => match[1]);
const uniqueExpressions = getDistinctValues(allMatches);
if (uniqueExpressions.length > 0) {
throw new Error(`unknown expression: ${printList(uniqueExpressions)}`);
}
}

View File

@@ -0,0 +1,12 @@
import { IProjectInformation } from '@/domain/IProjectInformation';
import { ProjectInformation } from '@/domain/ProjectInformation';
export function parseProjectInformation(
environment: NodeJS.ProcessEnv): IProjectInformation {
return new ProjectInformation(
environment.VUE_APP_NAME,
environment.VUE_APP_VERSION,
environment.VUE_APP_REPOSITORY_URL,
environment.VUE_APP_HOMEPAGE_URL,
);
}

View File

@@ -1,12 +1,15 @@
import { Script } from '@/domain/Script';
import { YamlScript } from 'js-yaml-loader!./application.yaml';
import { parseDocUrls } from './DocumentationParser';
import { RecommendationLevelNames, RecommendationLevel } from '@/domain/RecommendationLevel';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { IScriptCompiler } from './Compiler/IScriptCompiler';
import { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode';
import { createEnumParser, IEnumParser } from '../Common/Enum';
export function parseScript(yamlScript: YamlScript, compiler: IScriptCompiler): Script {
export function parseScript(
yamlScript: YamlScript, compiler: IScriptCompiler,
levelParser = createEnumParser(RecommendationLevel)): Script {
validateScript(yamlScript);
if (!compiler) {
throw new Error('undefined compiler');
@@ -15,23 +18,15 @@ export function parseScript(yamlScript: YamlScript, compiler: IScriptCompiler):
/* name */ yamlScript.name,
/* code */ parseCode(yamlScript, compiler),
/* docs */ parseDocUrls(yamlScript),
/* level */ getLevel(yamlScript.recommend));
/* level */ parseLevel(yamlScript.recommend, levelParser));
return script;
}
function getLevel(level: string): RecommendationLevel | undefined {
function parseLevel(level: string, parser: IEnumParser<RecommendationLevel>): 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];
return parser.parseEnum(level, 'level');
}
function parseCode(yamlScript: YamlScript, compiler: IScriptCompiler): IScriptCode {

View File

@@ -0,0 +1,36 @@
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { YamlScriptingDefinition } from 'js-yaml-loader!./application.yaml';
import { ScriptingDefinition } from '@/domain/ScriptingDefinition';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IProjectInformation } from '@/domain/IProjectInformation';
import { createEnumParser } from '../Common/Enum';
import { generateIlCode } from './Compiler/ILCode';
export function parseScriptingDefinition(
definition: YamlScriptingDefinition,
info: IProjectInformation,
date = new Date(),
languageParser = createEnumParser(ScriptingLanguage)): IScriptingDefinition {
if (!info) {
throw new Error('undefined info');
}
if (!definition) {
throw new Error('undefined definition');
}
const language = languageParser.parseEnum(definition.language, 'language');
const startCode = applySubstitutions(definition.startCode, info, date);
const endCode = applySubstitutions(definition.endCode, info, date);
return new ScriptingDefinition(
language,
startCode,
endCode,
);
}
function applySubstitutions(code: string, info: IProjectInformation, date: Date): string {
let ilCode = generateIlCode(code);
ilCode = ilCode.substituteParameter('homepage', info.homepage);
ilCode = ilCode.substituteParameter('version', info.version);
ilCode = ilCode.substituteParameter('date', date.toUTCString());
return ilCode.compile();
}

View File

@@ -0,0 +1,20 @@
import { IApplicationContext } from './IApplicationContext';
import { IApplication } from '@/domain/IApplication';
import { IApplicationState } from './IApplicationState';
import { ApplicationState } from './ApplicationState';
import applicationFile from 'js-yaml-loader!@/application/application.yaml';
import { parseApplication } from '../Parser/ApplicationParser';
export function createContext(): IApplicationContext {
const application = parseApplication(applicationFile);
const context = new ApplicationContext(application);
return context;
}
export class ApplicationContext implements IApplicationContext {
public readonly state: IApplicationState;
public constructor(public readonly app: IApplication) {
this.state = new ApplicationState(app);
}
}

View File

@@ -0,0 +1,9 @@
import { ApplicationContext } from './ApplicationContext';
import { IApplicationContext } from '@/application/State/IApplicationContext';
import applicationFile from 'js-yaml-loader!@/application/application.yaml';
import { parseApplication } from '@/application/Parser/ApplicationParser';
export function buildContext(): IApplicationContext {
const application = parseApplication(applicationFile);
return new ApplicationContext(application);
}

View File

@@ -3,43 +3,18 @@ import { IUserFilter } from './Filter/IUserFilter';
import { ApplicationCode } from './Code/ApplicationCode';
import { UserSelection } from './Selection/UserSelection';
import { IUserSelection } from './Selection/IUserSelection';
import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy';
import { Signal } from '@/infrastructure/Events/Signal';
import { parseApplication } from '../Parser/ApplicationParser';
import { IApplicationState } from './IApplicationState';
import { Script } from '@/domain/Script';
import { IApplication } from '@/domain/IApplication';
import { IApplicationCode } from './Code/IApplicationCode';
import applicationFile from 'js-yaml-loader!@/application/application.yaml';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
/** Mutatable singleton application state that's the single source of truth throughout the application */
export class ApplicationState implements IApplicationState {
/** Get singleton application state */
public static GetAsync(): Promise<IApplicationState> {
return ApplicationState.instance.getValueAsync();
}
/** Application instance with all scripts. */
private static instance = new AsyncLazy<IApplicationState>(() => {
const application = parseApplication(applicationFile);
const selectedScripts = new Array<Script>();
const state = new ApplicationState(application, selectedScripts);
return Promise.resolve(state);
});
public readonly code: IApplicationCode;
public readonly stateChanged = new Signal<IApplicationState>();
public readonly selection: IUserSelection;
public readonly filter: IUserFilter;
private constructor(
/** Inner instance of the all scripts */
public readonly app: IApplication,
/** Initially selected scripts */
public readonly defaultScripts: Script[]) {
this.selection = new UserSelection(app, defaultScripts.map((script) => new SelectedScript(script, false)));
this.code = new ApplicationCode(this.selection, app.info.version);
public constructor(readonly app: IApplication) {
this.selection = new UserSelection(app, []);
this.code = new ApplicationCode(this.selection, app.scripting);
this.filter = new UserFilter(app);
}
}

View File

@@ -7,6 +7,7 @@ import { UserScriptGenerator } from './Generation/UserScriptGenerator';
import { Signal } from '@/infrastructure/Events/Signal';
import { IApplicationCode } from './IApplicationCode';
import { IUserScriptGenerator } from './Generation/IUserScriptGenerator';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
export class ApplicationCode implements IApplicationCode {
public readonly changed = new Signal<ICodeChangedEvent>();
@@ -16,10 +17,10 @@ export class ApplicationCode implements IApplicationCode {
constructor(
userSelection: IUserSelection,
private readonly version: string,
private readonly scriptingDefinition: IScriptingDefinition,
private readonly generator: IUserScriptGenerator = new UserScriptGenerator()) {
if (!userSelection) { throw new Error('userSelection is null or undefined'); }
if (!version) { throw new Error('version is null or undefined'); }
if (!scriptingDefinition) { throw new Error('scriptingDefinition is null or undefined'); }
if (!generator) { throw new Error('generator is null or undefined'); }
this.setCode(userSelection.selectedScripts);
userSelection.changed.on((scripts) => {
@@ -29,7 +30,7 @@ export class ApplicationCode implements IApplicationCode {
private setCode(scripts: ReadonlyArray<SelectedScript>): void {
const oldScripts = Array.from(this.scriptPositions.keys());
const code = this.generator.buildCode(scripts, this.version);
const code = this.generator.buildCode(scripts, this.scriptingDefinition);
this.current = code.code;
this.scriptPositions = code.scriptPositions;
const event = new CodeChangedEvent(code.code, oldScripts, code.scriptPositions);

View File

@@ -1,7 +1,9 @@
import { ICodeBuilder } from './ICodeBuilder';
const NewLine = '\n';
const TotalFunctionSeparatorChars = 58;
export class CodeBuilder {
export class CodeBuilder implements ICodeBuilder {
private readonly lines = new Array<string>();
// Returns current line starting from 0 (no lines), or 1 (have single line)
@@ -54,7 +56,7 @@ export class CodeBuilder {
return this
.appendTrailingHyphensCommentLine()
.appendCommentLine(firstHyphens + sectionName + secondHyphens)
.appendTrailingHyphensCommentLine();
.appendTrailingHyphensCommentLine(TotalFunctionSeparatorChars);
}
public toString(): string {

View File

@@ -0,0 +1,9 @@
export interface ICodeBuilder {
currentLine: number;
appendLine(code?: string): ICodeBuilder;
appendTrailingHyphensCommentLine(totalRepeatHyphens: number): ICodeBuilder;
appendCommentLine(commentLine?: string): ICodeBuilder;
appendCommentLineWithHyphensAround(sectionName: string, totalRepeatHyphens: number): ICodeBuilder;
appendFunction(name: string, code: string): ICodeBuilder;
toString(): string;
}

View File

@@ -1,7 +1,9 @@
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { IUserScript } from './IUserScript';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
export interface IUserScriptGenerator {
buildCode(
selectedScripts: ReadonlyArray<SelectedScript>,
version: string): IUserScript;
scriptingDefinition: IScriptingDefinition): IUserScript;
}

View File

@@ -4,55 +4,54 @@ import { CodeBuilder } from './CodeBuilder';
import { ICodePosition } from '@/application/State/Code/Position/ICodePosition';
import { CodePosition } from '../Position/CodePosition';
import { IUserScript } from './IUserScript';
export const adminRightsScript = {
name: 'Ensure admin privileges',
code: 'fltmc >nul 2>&1 || (\n' +
' echo Administrator privileges are required.\n' +
' PowerShell Start -Verb RunAs \'%0\' 2> nul || (\n' +
' echo Right-click on the script and select "Run as administrator".\n' +
' pause & exit 1\n' +
' )\n' +
' exit 0\n' +
')',
};
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ICodeBuilder } from './ICodeBuilder';
export class UserScriptGenerator implements IUserScriptGenerator {
public buildCode(selectedScripts: ReadonlyArray<SelectedScript>, version: string): IUserScript {
if (!selectedScripts) { throw new Error('scripts is undefined'); }
if (!version) { throw new Error('version is undefined'); }
constructor(private readonly codeBuilderFactory: () => ICodeBuilder = () => new CodeBuilder()) {
}
public buildCode(
selectedScripts: ReadonlyArray<SelectedScript>,
scriptingDefinition: IScriptingDefinition): IUserScript {
if (!selectedScripts) { throw new Error('undefined scripts'); }
if (!scriptingDefinition) { throw new Error('undefined definition'); }
let scriptPositions = new Map<SelectedScript, ICodePosition>();
if (!selectedScripts.length) {
return { code: '', scriptPositions };
}
const builder = initializeCode(version);
let builder = this.codeBuilderFactory();
builder = initializeCode(scriptingDefinition.startCode, builder);
for (const selection of selectedScripts) {
scriptPositions = appendSelection(selection, scriptPositions, builder);
}
const code = finalizeCode(builder);
const code = finalizeCode(builder, scriptingDefinition.endCode);
return { code, scriptPositions };
}
}
function initializeCode(version: string): CodeBuilder {
return new CodeBuilder()
.appendLine('@echo off')
.appendCommentLine(`https://privacy.sexy — v${version}${new Date().toUTCString()}`)
.appendFunction(adminRightsScript.name, adminRightsScript.code)
function initializeCode(startCode: string, builder: ICodeBuilder): ICodeBuilder {
if (!startCode) {
return builder;
}
return builder
.appendLine(startCode)
.appendLine();
}
function finalizeCode(builder: CodeBuilder): string {
function finalizeCode(builder: ICodeBuilder, endCode: string): string {
if (!endCode) {
return builder.toString();
}
return builder.appendLine()
.appendLine('pause')
.appendLine('exit /b 0')
.appendLine(endCode)
.toString();
}
function appendSelection(
selection: SelectedScript,
scriptPositions: Map<SelectedScript, ICodePosition>,
builder: CodeBuilder): Map<SelectedScript, ICodePosition> {
builder: ICodeBuilder): Map<SelectedScript, ICodePosition> {
const startPosition = builder.currentLine + 1;
appendCode(selection, builder);
const endPosition = builder.currentLine - 1;
@@ -61,7 +60,7 @@ function appendSelection(
return scriptPositions;
}
function appendCode(selection: SelectedScript, builder: CodeBuilder) {
function appendCode(selection: SelectedScript, builder: ICodeBuilder) {
const name = selection.revert ? `${selection.script.name} (revert)` : selection.script.name;
const scriptCode = selection.revert ? selection.script.code.revert : selection.script.code.execute;
builder.appendFunction(name, scriptCode);

View File

@@ -0,0 +1,7 @@
import { IApplication } from '@/domain/IApplication';
import { IApplicationState } from './IApplicationState';
export interface IApplicationContext {
readonly app: IApplication;
readonly state: IApplicationState;
}

View File

@@ -1,15 +1,10 @@
import { IApplication } from './../../domain/IApplication';
import { IUserFilter } from './Filter/IUserFilter';
import { IUserSelection } from './Selection/IUserSelection';
import { ISignal } from '@/infrastructure/Events/ISignal';
import { IApplicationCode } from './Code/IApplicationCode';
export { IUserSelection, IApplicationCode, IUserFilter };
export interface IApplicationState {
/** Event that fires when the application states changes with new application state as parameter */
readonly code: IApplicationCode;
readonly filter: IUserFilter;
readonly stateChanged: ISignal<IApplicationState>;
readonly selection: IUserSelection;
readonly app: IApplication;
}

View File

@@ -1,4 +1,22 @@
# Structure documented in "docs/application-file.md"
os: windows
scripting:
language: batchfile
startCode: |-
@echo off
:: {{ $homepage }} — v{{ $version }} — {{ $date }}
:: Ensure admin privileges
fltmc >nul 2>&1 || (
echo Administrator privileges are required.
PowerShell Start -Verb RunAs '%0' 2> nul || (
echo Right-click on the script and select "Run as administrator".
pause & exit 1
)
exit 0
)
endCode: |-
pause
exit /b 0
actions:
-
category: Privacy cleanup

View File

@@ -1,19 +1,21 @@
declare module 'js-yaml-loader!*' {
export interface ApplicationYaml {
actions: ReadonlyArray<YamlCategory>;
functions: ReadonlyArray<YamlFunction> | undefined;
export interface YamlApplication {
readonly os: string;
readonly scripting: YamlScriptingDefinition;
readonly actions: ReadonlyArray<YamlCategory>;
readonly functions?: ReadonlyArray<YamlFunction>;
}
export interface YamlCategory extends YamlDocumentable {
children: ReadonlyArray<CategoryOrScript>;
category: string;
readonly children: ReadonlyArray<CategoryOrScript>;
readonly category: string;
}
export type CategoryOrScript = YamlCategory | YamlScript;
export type DocumentationUrls = ReadonlyArray<string> | string;
export interface YamlDocumentable {
docs?: DocumentationUrls;
readonly docs?: DocumentationUrls;
}
export interface YamlFunction {
@@ -42,6 +44,13 @@ declare module 'js-yaml-loader!*' {
recommend: string | undefined;
}
const content: ApplicationYaml;
export interface YamlScriptingDefinition {
readonly language: string;
readonly fileExtension: string;
readonly startCode: string;
readonly endCode: string;
}
const content: YamlApplication;
export default content;
}

View File

@@ -1,9 +1,12 @@
import { getEnumNames, getEnumValues } from '@/application/Common/Enum';
import { IEntity } from '../infrastructure/Entity/IEntity';
import { ICategory } from './ICategory';
import { IScript } from './IScript';
import { IApplication } from './IApplication';
import { IProjectInformation } from './IProjectInformation';
import { RecommendationLevel, RecommendationLevelNames, RecommendationLevels } from './RecommendationLevel';
import { RecommendationLevel } from './RecommendationLevel';
import { OperatingSystem } from './OperatingSystem';
import { IScriptingDefinition } from './IScriptingDefinition';
export class Application implements IApplication {
public get totalScripts(): number { return this.queryable.allScripts.length; }
@@ -12,12 +15,18 @@ export class Application implements IApplication {
private readonly queryable: IQueryableApplication;
constructor(
public readonly os: OperatingSystem,
public readonly info: IProjectInformation,
public readonly actions: ReadonlyArray<ICategory>) {
public readonly actions: ReadonlyArray<ICategory>,
public readonly scripting: IScriptingDefinition) {
if (!info) {
throw new Error('info is undefined');
throw new Error('undefined info');
}
if (!scripting) {
throw new Error('undefined scripting definition');
}
this.queryable = makeQueryable(actions);
ensureValidOs(os);
ensureValid(this.queryable);
ensureNoDuplicates(this.queryable.allCategories);
ensureNoDuplicates(this.queryable.allScripts);
@@ -50,13 +59,25 @@ export class Application implements IApplication {
}
}
function ensureValidOs(os: OperatingSystem): void {
if (os === undefined) {
throw new Error('undefined os');
}
if (os === OperatingSystem.Unknown) {
throw new Error('unknown os');
}
if (!(os in OperatingSystem)) {
throw new Error(`os "${os}" is out of range`);
}
}
function ensureNoDuplicates<TKey>(entities: ReadonlyArray<IEntity<TKey>>) {
const totalOccurencesById = new Map<TKey, number>();
const totalOccurrencesById = new Map<TKey, number>();
for (const entity of entities) {
totalOccurencesById.set(entity.id, (totalOccurencesById.get(entity.id) || 0) + 1);
totalOccurrencesById.set(entity.id, (totalOccurrencesById.get(entity.id) || 0) + 1);
}
const duplicatedIds = new Array<TKey>();
totalOccurencesById.forEach((index, id) => {
totalOccurrencesById.forEach((index, id) => {
if (index > 1) {
duplicatedIds.push(id);
}
@@ -89,7 +110,7 @@ function ensureValidScripts(allScripts: readonly IScript[]) {
if (!allScripts || allScripts.length === 0) {
throw new Error('Application must consist of at least one script');
}
for (const level of RecommendationLevels) {
for (const level of getEnumValues(RecommendationLevel)) {
if (allScripts.every((script) => script.level !== level)) {
throw new Error(`none of the scripts are recommended as ${RecommendationLevel[level]}`);
}
@@ -143,7 +164,7 @@ function makeQueryable(
function groupByLevel(allScripts: readonly IScript[]): Map<RecommendationLevel, readonly IScript[]> {
const map = new Map<RecommendationLevel, readonly IScript[]>();
for (const levelName of RecommendationLevelNames) {
for (const levelName of getEnumNames(RecommendationLevel)) {
const level = RecommendationLevel[levelName];
const scripts = allScripts.filter((script) => script.level !== undefined && script.level <= level);
map.set(level, scripts);

View File

@@ -2,9 +2,14 @@ import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
import { IProjectInformation } from './IProjectInformation';
import { RecommendationLevel } from './RecommendationLevel';
import { OperatingSystem } from './OperatingSystem';
import { IScriptingDefinition } from './IScriptingDefinition';
export interface IApplication {
readonly info: IProjectInformation;
readonly scripting: IScriptingDefinition;
readonly os: OperatingSystem;
readonly totalScripts: number;
readonly totalCategories: number;
readonly actions: ReadonlyArray<ICategory>;

View File

@@ -0,0 +1,8 @@
import { ScriptingLanguage } from './ScriptingLanguage';
export interface IScriptingDefinition {
readonly fileExtension: string;
readonly language: ScriptingLanguage;
readonly startCode: string;
readonly endCode: string;
}

View File

@@ -2,10 +2,3 @@ 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

@@ -0,0 +1,32 @@
import { ScriptingLanguage } from './ScriptingLanguage';
import { IScriptingDefinition } from './IScriptingDefinition';
export class ScriptingDefinition implements IScriptingDefinition {
public readonly fileExtension: string;
constructor(
public readonly language: ScriptingLanguage,
public readonly startCode: string,
public readonly endCode: string,
) {
this.fileExtension = findExtension(language);
validateCode(startCode, 'start code');
validateCode(endCode, 'end code');
}
}
function findExtension(language: ScriptingLanguage): string {
switch (language) {
case ScriptingLanguage.bash:
return 'sh';
case ScriptingLanguage.batchfile:
return 'bat';
default:
throw new Error(`unsupported language: ${language}`);
}
}
function validateCode(code: string, name: string) {
if (!code) {
throw new Error(`undefined ${name}`);
}
}

View File

@@ -0,0 +1,4 @@
export enum ScriptingLanguage {
batchfile = 0,
bash = 1,
}

View File

@@ -32,8 +32,8 @@ export default class CardList extends StatefulVue {
public activeCategoryId?: number = null;
public async mounted() {
const state = await this.getCurrentStateAsync();
this.setCategories(state.app.actions);
const context = await this.getCurrentContextAsync();
this.setCategories(context.app.actions);
this.onOutsideOfActiveCardClicked((element) => {
if (hasDirective(element)) {
return;

View File

@@ -69,8 +69,8 @@ export default class CardListItem extends StatefulVue {
}
public async mounted() {
const state = await this.getCurrentStateAsync();
state.selection.changed.on(() => {
const context = await this.getCurrentContextAsync();
context.state.selection.changed.on(() => {
this.updateStateAsync(this.categoryId);
});
this.updateStateAsync(this.categoryId);
@@ -78,11 +78,12 @@ export default class CardListItem extends StatefulVue {
@Watch('categoryId')
public async updateStateAsync(value: |number) {
const state = await this.getCurrentStateAsync();
const category = !value ? undefined : state.app.findCategory(this.categoryId);
const context = await this.getCurrentContextAsync();
const category = !value ? undefined : context.app.findCategory(this.categoryId);
this.cardTitle = category ? category.name : undefined;
this.isAnyChildSelected = category ? state.selection.isAnySelected(category) : false;
this.areAllChildrenSelected = category ? state.selection.areAllSelected(category) : false;
const currentSelection = context.state.selection;
this.isAnyChildSelected = category ? currentSelection.isAnySelected(category) : false;
this.areAllChildrenSelected = category ? currentSelection.areAllSelected(category) : false;
}
}

View File

@@ -42,24 +42,19 @@
private filtered?: IFilterResult;
public async mounted() {
const state = await this.getCurrentStateAsync();
// React to state changes
state.selection.changed.on(this.handleSelectionChanged);
state.filter.filterRemoved.on(this.handleFilterRemoved);
state.filter.filtered.on(this.handleFiltered);
// Update initial state
await this.initializeNodesAsync(this.categoryId);
await this.initializeFilter(state.filter.currentFilter);
const context = await this.getCurrentContextAsync();
this.beginReactingToStateChanges(context.state);
this.setInitialState(context.state);
}
public async toggleNodeSelectionAsync(event: INodeSelectedEvent) {
const state = await this.getCurrentStateAsync();
const context = await this.getCurrentContextAsync();
switch (event.node.type) {
case NodeType.Category:
toggleCategoryNodeSelection(event, state);
toggleCategoryNodeSelection(event, context.state);
break;
case NodeType.Script:
toggleScriptNodeSelection(event, state);
toggleScriptNodeSelection(event, context.state);
break;
default:
throw new Error(`Unknown node type: ${event.node.id}`);
@@ -68,13 +63,13 @@
@Watch('categoryId')
public async initializeNodesAsync(categoryId?: number) {
const state = await this.getCurrentStateAsync();
const context = await this.getCurrentContextAsync();
if (categoryId) {
this.nodes = parseSingleCategory(categoryId, state.app);
this.nodes = parseSingleCategory(categoryId, context.app);
} else {
this.nodes = parseAllCategories(state.app);
this.nodes = parseAllCategories(context.app);
}
this.selectedNodeIds = state.selection.selectedScripts
this.selectedNodeIds = context.state.selection.selectedScripts
.map((selected) => getScriptNodeId(selected.script));
}
@@ -85,6 +80,17 @@
(category: ICategory) => node.id === getCategoryNodeId(category));
}
private beginReactingToStateChanges(state: IApplicationState) {
state.selection.changed.on(this.handleSelectionChanged);
state.filter.filterRemoved.on(this.handleFilterRemoved);
state.filter.filtered.on(this.handleFiltered);
}
private setInitialState(state: IApplicationState) {
this.initializeNodesAsync(this.categoryId);
this.initializeFilter(state.filter.currentFilter);
}
private initializeFilter(currentFilter: IFilterResult | undefined) {
if (!currentFilter) {
this.handleFilterRemoved();

View File

@@ -29,19 +29,20 @@
public async mounted() {
await this.onNodeChangedAsync(this.node);
const state = await this.getCurrentStateAsync();
this.updateState(state.selection.selectedScripts);
state.selection.changed.on((scripts) => this.updateState(scripts));
const context = await this.getCurrentContextAsync();
const currentSelection = context.state.selection;
this.updateState(currentSelection.selectedScripts);
currentSelection.changed.on((scripts) => this.updateState(scripts));
}
@Watch('node') public async onNodeChangedAsync(node: INode) {
const state = await this.getCurrentStateAsync();
this.handler = getReverter(node, state.app);
const context = await this.getCurrentContextAsync();
this.handler = getReverter(node, context.app);
}
public async onRevertToggledAsync() {
const state = await this.getCurrentStateAsync();
this.handler.selectWithRevertState(this.isReverted, state.selection);
const context = await this.getCurrentContextAsync();
this.handler.selectWithRevertState(this.isReverted, context.state.selection);
}
private updateState(scripts: ReadonlyArray<SelectedScript>) {

View File

@@ -49,6 +49,7 @@ import { IApplicationState } from '@/application/State/IApplicationState';
import { IScript } from '@/domain/IScript';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { IApplicationContext } from '../../../application/State/IApplicationContext';
enum SelectionState {
Standard,
@@ -67,66 +68,79 @@ export default class TheSelector extends StatefulVue {
public currentSelection = SelectionState.None;
public async mounted() {
const state = await this.getCurrentStateAsync();
this.updateSelections(state);
state.selection.changed.on(() => {
this.updateSelections(state);
});
const context = await this.getCurrentContextAsync();
this.updateSelections(context);
this.beginReactingToChanges(context);
}
public async selectAsync(type: SelectionState): Promise<void> {
if (this.currentSelection === type) {
return;
}
const state = await this.getCurrentStateAsync();
selectType(state, type);
const context = await this.getCurrentContextAsync();
selectType(context, type);
}
private updateSelections(state: IApplicationState) {
this.currentSelection = getCurrentSelectionState(state);
private updateSelections(context: IApplicationContext) {
this.currentSelection = getCurrentSelectionState(context);
}
private beginReactingToChanges(context: IApplicationContext) {
context.state.selection.changed.on(() => {
this.updateSelections(context);
});
}
}
interface ITypeSelector {
isSelected: (state: IApplicationState) => boolean;
select: (state: IApplicationState) => void;
isSelected: (context: IApplicationContext) => boolean;
select: (context: IApplicationContext) => void;
}
const selectors = new Map<SelectionState, ITypeSelector>([
[SelectionState.None, {
select: (state) => state.selection.deselectAll(),
isSelected: (state) => state.selection.totalSelected === 0,
select: (context) =>
context.state.selection.deselectAll(),
isSelected: (context) =>
context.state.selection.totalSelected === 0,
}],
[SelectionState.Standard, {
select: (state) => state.selection.selectOnly(state.app.getScriptsByLevel(RecommendationLevel.Standard)),
isSelected: (state) => hasAllSelectedLevelOf(RecommendationLevel.Standard, state),
select: (context) =>
context.state.selection.selectOnly(
context.app.getScriptsByLevel(RecommendationLevel.Standard)),
isSelected: (context) =>
hasAllSelectedLevelOf(RecommendationLevel.Standard, context),
}],
[SelectionState.Strict, {
select: (state) => state.selection.selectOnly(state.app.getScriptsByLevel(RecommendationLevel.Strict)),
isSelected: (state) => hasAllSelectedLevelOf(RecommendationLevel.Strict, state),
select: (context) =>
context.state.selection.selectOnly(context.app.getScriptsByLevel(RecommendationLevel.Strict)),
isSelected: (context) =>
hasAllSelectedLevelOf(RecommendationLevel.Strict, context),
}],
[SelectionState.All, {
select: (state) => state.selection.selectAll(),
isSelected: (state) => state.selection.totalSelected === state.app.totalScripts,
select: (context) =>
context.state.selection.selectAll(),
isSelected: (context) =>
context.state.selection.totalSelected === context.app.totalScripts,
}],
]);
function selectType(state: IApplicationState, type: SelectionState) {
function selectType(context: IApplicationContext, type: SelectionState) {
const selector = selectors.get(type);
selector.select(state);
selector.select(context);
}
function getCurrentSelectionState(state: IApplicationState): SelectionState {
function getCurrentSelectionState(context: IApplicationContext): SelectionState {
for (const [type, selector] of Array.from(selectors.entries())) {
if (selector.isSelected(state)) {
if (selector.isSelected(context)) {
return type;
}
}
return SelectionState.Custom;
}
function hasAllSelectedLevelOf(level: RecommendationLevel, state: IApplicationState) {
const scripts = state.app.getScriptsByLevel(level);
const selectedScripts = state.selection.selectedScripts;
function hasAllSelectedLevelOf(level: RecommendationLevel, context: IApplicationContext) {
const scripts = context.app.getScriptsByLevel(level);
const selectedScripts = context.state.selection.selectedScripts;
return areAllSelected(scripts, selectedScripts);
}

View File

@@ -73,12 +73,13 @@
public searchHasMatches = false;
public async mounted() {
const state = await this.getCurrentStateAsync();
this.repositoryUrl = state.app.info.repositoryWebUrl;
state.filter.filterRemoved.on(() => {
const context = await this.getCurrentContextAsync();
this.repositoryUrl = context.app.info.repositoryWebUrl;
const filter = context.state.filter;
filter.filterRemoved.on(() => {
this.isSearching = false;
});
state.filter.filtered.on((result: IFilterResult) => {
filter.filtered.on((result: IFilterResult) => {
this.searchQuery = result.query;
this.isSearching = true;
this.searchHasMatches = result.hasAnyMatches();
@@ -86,8 +87,9 @@
}
public async clearSearchQueryAsync() {
const state = await this.getCurrentStateAsync();
state.filter.removeFilter();
const context = await this.getCurrentContextAsync();
const filter = context.state.filter;
filter.removeFilter();
}
public onGroupingChanged(group: Grouping) {

View File

@@ -1,11 +1,13 @@
import { ApplicationState } from '@/application/State/ApplicationState';
import { IApplicationState } from '@/application/State/IApplicationState';
import { Vue } from 'vue-property-decorator';
import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy';
import { IApplicationContext } from '@/application/State/IApplicationContext';
import { buildContext } from '@/application/State/ApplicationContextProvider';
export abstract class StatefulVue extends Vue {
public isLoading = true;
private static instance = new AsyncLazy<IApplicationContext>(
() => Promise.resolve(buildContext()));
protected getCurrentStateAsync(): Promise<IApplicationState> {
return ApplicationState.GetAsync();
protected getCurrentContextAsync(): Promise<IApplicationContext> {
return StatefulVue.instance.getValueAsync();
}
}

View File

@@ -10,6 +10,7 @@ import 'ace-builds/webpack-resolver';
import { CodeBuilder } from '@/application/State/Code/Generation/CodeBuilder';
import { ICodeChangedEvent } from '@/application/State/Code/Event/ICodeChangedEvent';
import { IScript } from '@/domain/IScript';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
const NothingChosenCode =
new CodeBuilder()
@@ -38,10 +39,11 @@ export default class TheCodeArea extends StatefulVue {
@Prop() private theme!: string;
public async mounted() {
this.editor = initializeEditor(this.theme, this.editorId);
const state = await this.getCurrentStateAsync();
this.editor.setValue(state.code.current || NothingChosenCode, 1);
state.code.changed.on((code) => this.updateCode(code));
const context = await this.getCurrentContextAsync();
this.editor = initializeEditor(this.theme, this.editorId, context.app.scripting.language);
const appCode = context.state.code;
this.editor.setValue(appCode.current || NothingChosenCode, 1);
appCode.changed.on((code) => this.updateCode(code));
}
private updateCode(event: ICodeChangedEvent) {
@@ -93,10 +95,10 @@ export default class TheCodeArea extends StatefulVue {
}
}
function initializeEditor(theme: string, editorId: string): ace.Ace.Editor {
const lang = 'batchfile';
function initializeEditor(theme: string, editorId: string, language: ScriptingLanguage): ace.Ace.Editor {
theme = theme || 'github';
const editor = ace.edit(editorId);
const lang = getLanguage(language);
editor.getSession().setMode(`ace/mode/${lang}`);
editor.setTheme(`ace/theme/${theme}`);
editor.setReadOnly(true);
@@ -105,6 +107,15 @@ function initializeEditor(theme: string, editorId: string): ace.Ace.Editor {
return editor;
}
function getLanguage(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile:
return 'batchfile';
default:
throw new Error('unkown language');
}
}
</script>
<style lang="scss">

View File

@@ -1,6 +1,5 @@
<template>
<div class="container" v-if="hasCode">
</IconButton>
<IconButton
:text="this.isDesktop ? 'Save' : 'Download'"
v-on:click="saveCodeAsync"
@@ -22,6 +21,9 @@ import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import { Clipboard } from '@/infrastructure/Clipboard';
import IconButton from './IconButton.vue';
import { Environment } from '@/application/Environment/Environment';
import { IApplicationCode } from '../application/State/IApplicationState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IApplicationContext } from '@/application/State/IApplicationContext';
@Component({
components: {
@@ -33,22 +35,44 @@ export default class TheCodeButtons extends StatefulVue {
public isDesktop = false;
public async mounted() {
const state = await this.getCurrentStateAsync();
this.isDesktop = Environment.CurrentEnvironment.isDesktop;
this.hasCode = state.code.current && state.code.current.length > 0;
state.code.changed.on((code) => {
this.hasCode = code && code.code.length > 0;
const code = await this.getCurrentCodeAsync();
this.hasCode = code.current && code.current.length > 0;
code.changed.on((newCode) => {
this.hasCode = newCode && newCode.code.length > 0;
});
this.isDesktop = Environment.CurrentEnvironment.isDesktop;
}
public async copyCodeAsync() {
const state = await this.getCurrentStateAsync();
Clipboard.copyText(state.code.current);
const code = await this.getCurrentCodeAsync();
Clipboard.copyText(code.current);
}
public async saveCodeAsync() {
const state = await this.getCurrentStateAsync();
SaveFileDialog.saveFile(state.code.current, 'privacy-script.bat', FileType.BatchFile);
const context = await this.getCurrentContextAsync();
saveCode(context);
}
private async getCurrentCodeAsync(): Promise<IApplicationCode> {
const context = await this.getCurrentContextAsync();
const code = context.state.code;
return code;
}
}
function saveCode(context: IApplicationContext) {
const fileName = `privacy-script.${context.app.scripting.fileExtension}`;
const content = context.state.code.current;
const type = getType(context.app.scripting.language);
SaveFileDialog.saveFile(content, fileName, FileType.BatchFile);
}
function getType(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile:
return FileType.BatchFile;
default:
throw new Error('unknown file type');
}
}
</script>

View File

@@ -38,8 +38,8 @@ export default class DownloadUrlListItem extends StatefulVue {
}
private async getDownloadUrlAsync(os: OperatingSystem): Promise<string> {
const state = await this.getCurrentStateAsync();
return state.app.info.getDownloadUrl(os);
const context = await this.getCurrentContextAsync();
return context.app.info.getDownloadUrl(os);
}
}

View File

@@ -47,9 +47,9 @@ export default class TheFooter extends StatefulVue {
}
public async mounted() {
const state = await this.getCurrentStateAsync();
this.repositoryUrl = state.app.info.repositoryWebUrl;
this.feedbackUrl = state.app.info.feedbackUrl;
const context = await this.getCurrentContextAsync();
this.repositoryUrl = context.app.info.repositoryWebUrl;
this.feedbackUrl = context.app.info.feedbackUrl;
}
}
</script>

View File

@@ -74,7 +74,7 @@ export default class TheFooter extends StatefulVue {
}
public async mounted() {
const state = await this.getCurrentStateAsync();
const state = await this.getCurrentContextAsync();
const info = state.app.info;
this.version = info.version;
this.homepageUrl = info.homepage;

View File

@@ -15,8 +15,8 @@ export default class TheHeader extends StatefulVue {
public subtitle = '';
public async mounted() {
const state = await this.getCurrentStateAsync();
this.title = state.app.info.name;
const context = await this.getCurrentContextAsync();
this.title = context.app.info.name;
}
}
</script>

View File

@@ -24,19 +24,20 @@ export default class TheSearchBar extends StatefulVue {
public searchQuery = '';
public async mounted() {
const state = await this.getCurrentStateAsync();
const totalScripts = state.app.totalScripts;
const context = await this.getCurrentContextAsync();
const totalScripts = context.app.totalScripts;
this.searchPlaceHolder = `Search in ${totalScripts} scripts`;
this.beginReacting(state.filter);
this.beginReacting(context.state.filter);
}
@Watch('searchQuery')
public async updateFilterAsync(filter: |string) {
const state = await this.getCurrentStateAsync();
if (!filter) {
state.filter.removeFilter();
public async updateFilterAsync(newFilter: |string) {
const context = await this.getCurrentContextAsync();
const filter = context.state.filter;
if (!newFilter) {
filter.removeFilter();
} else {
state.filter.setFilter(filter);
filter.setFilter(newFilter);
}
}

View File

@@ -0,0 +1,95 @@
import 'mocha';
import { expect } from 'chai';
import { getEnumNames, getEnumValues, createEnumParser } from '@/application/Common/Enum';
describe('Enum', () => {
describe('createEnumParser', () => {
enum ParsableEnum { Value1, value2 }
describe('parses as expected', () => {
// arrange
const testCases = [
{
name: 'case insensitive',
value: 'vALuE1',
expected: ParsableEnum.Value1,
},
{
name: 'exact match',
value: 'value2',
expected: ParsableEnum.value2,
},
];
// act
for (const testCase of testCases) {
it(testCase.name, () => {
const parser = createEnumParser(ParsableEnum);
const actual = parser.parseEnum(testCase.value, 'non-important');
// assert
expect(actual).to.equal(testCase.expected);
});
}
});
describe('throws as expected', () => {
// arrange
const enumName = 'ParsableEnum';
const testCases = [
{
name: 'undefined',
value: undefined,
expectedError: `undefined ${enumName}`,
},
{
name: 'empty',
value: '',
expectedError: `undefined ${enumName}`,
},
{
name: 'out of range',
value: 'value3',
expectedError: `unknown ${enumName}: "value3"`,
},
{
name: 'out of range',
value: 'value3',
expectedError: `unknown ${enumName}: "value3"`,
},
{
name: 'unexpected type',
value: 55 as any,
expectedError: `unexpected type of ${enumName}: "number"`,
},
];
// act
for (const testCase of testCases) {
it(testCase.name, () => {
const parser = createEnumParser(ParsableEnum);
const act = () => parser.parseEnum(testCase.value, enumName);
// assert
expect(act).to.throw(testCase.expectedError);
});
}
});
});
describe('getEnumNames', () => {
it('parses as expected', () => {
// arrange
enum TestEnum { TestValue1, testValue2, testvalue3, TESTVALUE4 }
const expected = [ 'TestValue1', 'testValue2', 'testvalue3', 'TESTVALUE4' ];
// act
const actual = getEnumNames(TestEnum);
// assert
expect(expected.sort()).to.deep.equal(actual.sort());
});
});
describe('getEnumValues', () => {
it('parses as expected', () => {
// arrange
enum TestEnum { Red, Green, Blue }
const expected = [ TestEnum.Red, TestEnum.Green, TestEnum.Blue ];
// act
const actual = getEnumValues(TestEnum);
// assert
expect(expected.sort()).to.deep.equal(actual.sort());
});
});
});

View File

@@ -1,11 +1,16 @@
import { IEntity } from '@/infrastructure/Entity/IEntity';
import applicationFile, { YamlCategory, YamlScript, ApplicationYaml } from 'js-yaml-loader!@/application/application.yaml';
import applicationFile, { YamlCategory, YamlScript, YamlApplication, YamlScriptingDefinition } from 'js-yaml-loader!@/application/application.yaml';
import { parseApplication } from '@/application/Parser/ApplicationParser';
import 'mocha';
import { expect } from 'chai';
import { parseCategory } from '@/application/Parser/CategoryParser';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { ScriptCompilerStub } from '../../stubs/ScriptCompilerStub';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { parseScriptingDefinition } from '@/application/Parser/ScriptingDefinitionParser';
import { mockEnumParser } from '../../stubs/EnumParserStub';
describe('ApplicationParser', () => {
describe('parseApplication', () => {
@@ -23,78 +28,31 @@ describe('ApplicationParser', () => {
// assert
expect(act).to.throw(expectedError);
});
describe('actions', () => {
it('throws when undefined actions', () => {
// arrange
const sut: ApplicationYaml = { actions: undefined, functions: undefined };
const expectedError = 'application does not define any action';
const app = new YamlApplicationBuilder().withActions(undefined).build();
// act
const act = () => parseApplication(sut);
const act = () => parseApplication(app);
// assert
expect(act).to.throw(expectedError);
expect(act).to.throw('application does not define any action');
});
it('throws when has no actions', () => {
// arrange
const sut: ApplicationYaml = { actions: [], functions: undefined };
const expectedError = 'application does not define any action';
const app = new YamlApplicationBuilder().withActions([]).build();
// act
const act = () => parseApplication(sut);
const act = () => parseApplication(app);
// assert
expect(act).to.throw(expectedError);
});
describe('information', () => {
it('returns expected repository version', () => {
// arrange
const expected = 'expected-version';
const env = getProcessEnvironmentStub();
env.VUE_APP_VERSION = expected;
const sut: ApplicationYaml = { actions: [ getTestCategory() ], functions: undefined };
// act
const actual = parseApplication(sut, env).info.version;
// assert
expect(actual).to.be.equal(expected);
});
it('returns expected repository url', () => {
// arrange
const expected = 'https://expected-repository.url';
const env = getProcessEnvironmentStub();
env.VUE_APP_REPOSITORY_URL = expected;
const sut: ApplicationYaml = { actions: [ getTestCategory() ], functions: undefined };
// act
const actual = parseApplication(sut, env).info.repositoryUrl;
// assert
expect(actual).to.be.equal(expected);
});
it('returns expected name', () => {
// arrange
const expected = 'expected-app-name';
const env = getProcessEnvironmentStub();
env.VUE_APP_NAME = expected;
const sut: ApplicationYaml = { actions: [ getTestCategory() ], functions: undefined };
// act
const actual = parseApplication(sut, env).info.name;
// assert
expect(actual).to.be.equal(expected);
});
it('returns expected homepage url', () => {
// arrange
const expected = 'https://expected.sexy';
const env = getProcessEnvironmentStub();
env.VUE_APP_HOMEPAGE_URL = expected;
const sut: ApplicationYaml = { actions: [ getTestCategory() ], functions: undefined };
// act
const actual = parseApplication(sut, env).info.homepage;
// assert
expect(actual).to.be.equal(expected);
});
expect(act).to.throw('application does not define any action');
});
it('parses actions', () => {
// arrange
const actions = [ getTestCategory('test1'), getTestCategory('test2') ];
const compiler = new ScriptCompilerStub();
const expected = [ parseCategory(actions[0], compiler), parseCategory(actions[1], compiler) ];
const sut: ApplicationYaml = { actions, functions: undefined };
const app = new YamlApplicationBuilder().withActions(actions).build();
// act
const actual = parseApplication(sut).actions;
const actual = parseApplication(app).actions;
// assert
expect(excludingId(actual)).to.be.deep.equal(excludingId(expected));
function excludingId<TId>(array: ReadonlyArray<IEntity<TId>>) {
@@ -105,7 +63,117 @@ describe('ApplicationParser', () => {
}
});
});
describe('info', () => {
it('returns expected repository version', () => {
// arrange
const expected = 'expected-version';
const env = getProcessEnvironmentStub();
env.VUE_APP_VERSION = expected;
const app = new YamlApplicationBuilder().build();
// act
const actual = parseApplication(app, env).info.version;
// assert
expect(actual).to.be.equal(expected);
});
it('returns expected repository url', () => {
// arrange
const expected = 'https://expected-repository.url';
const env = getProcessEnvironmentStub();
env.VUE_APP_REPOSITORY_URL = expected;
const app = new YamlApplicationBuilder().build();
// act
const actual = parseApplication(app, env).info.repositoryUrl;
// assert
expect(actual).to.be.equal(expected);
});
it('returns expected name', () => {
// arrange
const expected = 'expected-app-name';
const env = getProcessEnvironmentStub();
env.VUE_APP_NAME = expected;
const app = new YamlApplicationBuilder().build();
// act
const actual = parseApplication(app, env).info.name;
// assert
expect(actual).to.be.equal(expected);
});
it('returns expected homepage url', () => {
// arrange
const expected = 'https://expected.sexy';
const env = getProcessEnvironmentStub();
env.VUE_APP_HOMEPAGE_URL = expected;
const app = new YamlApplicationBuilder().build();
// act
const actual = parseApplication(app, env).info.homepage;
// assert
expect(actual).to.be.equal(expected);
});
});
describe('scripting definition', () => {
it('parses scripting definition as expected', () => {
// arrange
const app = new YamlApplicationBuilder().build();
const information = parseProjectInformation(process.env);
const expected = parseScriptingDefinition(app.scripting, information);
// act
const actual = parseApplication(app).scripting;
// assert
expect(expected).to.deep.equal(actual);
});
});
describe('os', () => {
it('parses as expected', () => {
// arrange
const expectedOs = OperatingSystem.macOS;
const osText = 'macos';
const expectedName = 'os';
const app = new YamlApplicationBuilder()
.withOs(osText)
.build();
const parserMock = mockEnumParser(expectedName, osText, expectedOs);
const env = getProcessEnvironmentStub();
// act
const actual = parseApplication(app, env, parserMock);
// assert
expect(actual.os).to.equal(expectedOs);
});
});
});
});
class YamlApplicationBuilder {
private os = 'windows';
private actions: readonly YamlCategory[] = [ getTestCategory() ];
private scripting: YamlScriptingDefinition = getTestDefinition();
public withActions(actions: readonly YamlCategory[]): YamlApplicationBuilder {
this.actions = actions;
return this;
}
public withOs(os: string): YamlApplicationBuilder {
this.os = os;
return this;
}
public withScripting(scripting: YamlScriptingDefinition): YamlApplicationBuilder {
this.scripting = scripting;
return this;
}
public build(): YamlApplication {
return { os: this.os, scripting: this.scripting, actions: this.actions };
}
}
function getTestDefinition(): YamlScriptingDefinition {
return {
fileExtension: '.bat',
language: ScriptingLanguage[ScriptingLanguage.batchfile],
startCode: 'start',
endCode: 'end',
};
}
function getTestCategory(scriptPrefix = 'testScript'): YamlCategory {
return {

View File

@@ -0,0 +1,141 @@
import 'mocha';
import { expect } from 'chai';
import { generateIlCode } from '@/application/Parser/Compiler/ILCode';
describe('ILCode', () => {
describe('getUniqueParameterNames', () => {
// arrange
const testCases = [
{
name: 'empty parameters: returns an empty array',
code: 'no expressions',
expected: [ ],
},
{
name: 'single parameter: returns expected for single usage',
code: '{{ $single }}',
expected: [ 'single' ],
},
{
name: 'single parameter: returns distinct values for repeating parameters',
code: '{{ $singleRepeating }}, {{ $singleRepeating }}',
expected: [ 'singleRepeating' ],
},
{
name: 'multiple parameters: returns expected for single usage of each',
code: '{{ $firstParameter }}, {{ $secondParameter }}',
expected: [ 'firstParameter', 'secondParameter' ],
},
{
name: 'multiple parameters: returns distinct values for repeating parameters',
code: '{{ $firstParameter }}, {{ $firstParameter }}, {{ $firstParameter }} {{ $secondParameter }}, {{ $secondParameter }}',
expected: [ 'firstParameter', 'secondParameter' ],
},
];
for (const testCase of testCases) {
it(testCase.name, () => {
// act
const sut = generateIlCode(testCase.code);
const actual = sut.getUniqueParameterNames();
// assert
expect(actual).to.deep.equal(testCase.expected);
});
}
});
describe('substituteParameter', () => {
describe('substitutes by ignoring white spaces inside mustaches', () => {
// arrange
const mustacheVariations = [
'Hello {{ $test }}!',
'Hello {{$test }}!',
'Hello {{ $test}}!',
'Hello {{$test}}!'];
mustacheVariations.forEach((variation) => {
it(variation, () => {
// arrange
const ilCode = generateIlCode(variation);
const expected = 'Hello world!';
// act
const actual = ilCode
.substituteParameter('test', 'world')
.compile();
// assert
expect(actual).to.deep.equal(expected);
});
});
});
describe('substitutes as expected', () => {
// arrange
const testCases = [
{
name: 'single parameter',
code: 'Hello {{ $firstParameter }}!',
expected: 'Hello world!',
parameters: {
firstParameter: 'world',
},
},
{
name: 'single parameter repeated',
code: '{{ $firstParameter }} {{ $firstParameter }}!',
expected: 'hello hello!',
parameters: {
firstParameter: 'hello',
},
},
{
name: 'multiple parameters',
code: 'He{{ $firstParameter }} {{ $secondParameter }}!',
expected: 'Hello world!',
parameters: {
firstParameter: 'llo',
secondParameter: 'world',
},
},
{
name: 'multiple parameters repeated',
code: 'He{{ $firstParameter }} {{ $secondParameter }} and He{{ $firstParameter }} {{ $secondParameter }}!',
expected: 'Hello world and Hello world!',
parameters: {
firstParameter: 'llo',
secondParameter: 'world',
},
},
];
for (const testCase of testCases) {
it(testCase.name, () => {
// act
let ilCode = generateIlCode(testCase.code);
for (const parameterName of Object.keys(testCase.parameters)) {
const value = testCase.parameters[parameterName];
ilCode = ilCode.substituteParameter(parameterName, value);
}
const actual = ilCode.compile();
// assert
expect(actual).to.deep.equal(testCase.expected);
});
}
});
});
describe('compile', () => {
it('throws if there are expressions left', () => {
// arrange
const expectedError = 'unknown expression: "each"';
const code = '{{ each }}';
// act
const ilCode = generateIlCode(code);
const act = () => ilCode.compile();
// assert
expect(act).to.throw(expectedError);
});
it('returns code as it is if there are no expressions', () => {
// arrange
const expected = 'I should be the same!';
const ilCode = generateIlCode(expected);
// act
const actual = ilCode.compile();
// assert
expect(actual).to.equal(expected);
});
});
});

View File

@@ -215,30 +215,6 @@ describe('ScriptCompiler', () => {
});
});
describe('parameter substitution', () => {
describe('substitutes by ignoring whitespaces inside mustaches', () => {
// arrange
const mustacheVariations = [
'Hello {{ $test }}!',
'Hello {{$test }}!',
'Hello {{ $test}}!',
'Hello {{$test}}!'];
mustacheVariations.forEach((variation) => {
it(variation, () => {
// arrange
const env = new TestEnvironment({
code: variation,
parameters: {
test: 'world',
},
});
const expected = env.expect('Hello world!');
// act
const actual = env.sut.compile(env.script);
// assert
expect(actual).to.deep.equal(expected);
});
});
});
describe('substitutes as expected', () => {
it('with different parameters', () => {
// arrange
@@ -255,15 +231,15 @@ describe('ScriptCompiler', () => {
// assert
expect(actual).to.deep.equal(expected);
});
it('with same parameter repeated', () => {
it('with single parameter', () => {
// arrange
const env = new TestEnvironment({
code: '{{ $parameter }} {{ $parameter }}!',
code: '{{ $parameter }}!',
parameters: {
parameter: 'Hodor',
},
});
const expected = env.expect('Hodor Hodor!');
const expected = env.expect('Hodor!');
// act
const actual = env.sut.compile(env.script);
// assert
@@ -295,20 +271,6 @@ describe('ScriptCompiler', () => {
// assert
expect(act).to.throw(expectedError);
});
it('throws on unknown expressions', () => {
// arrange
const env = new TestEnvironment({
code: '{{ each }}',
parameters: {
parameter: undefined,
},
});
const expectedError = 'unknown expression: "each"';
// act
const act = () => env.sut.compile(env.script);
// assert
expect(act).to.throw(expectedError);
});
});
});
});

View File

@@ -0,0 +1,58 @@
import 'mocha';
import { expect } from 'chai';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
describe('ProjectInformationParser', () => {
describe('parseProjectInformation', () => {
it('parses expected repository version', () => {
// arrange
const expected = 'expected-version';
const env = getProcessEnvironmentStub();
env.VUE_APP_VERSION = expected;
// act
const info = parseProjectInformation(env);
// assert
expect(info.version).to.be.equal(expected);
});
it('parses expected repository url', () => {
// arrange
const expected = 'https://expected-repository.url';
const env = getProcessEnvironmentStub();
env.VUE_APP_REPOSITORY_URL = expected;
// act
const info = parseProjectInformation(env);
// assert
expect(info.repositoryUrl).to.be.equal(expected);
});
it('parses expected name', () => {
// arrange
const expected = 'expected-app-name';
const env = getProcessEnvironmentStub();
env.VUE_APP_NAME = expected;
// act
const info = parseProjectInformation(env);
// assert
expect(info.name).to.be.equal(expected);
});
it('parses expected homepage url', () => {
// arrange
const expected = 'https://expected.sexy';
const env = getProcessEnvironmentStub();
env.VUE_APP_HOMEPAGE_URL = expected;
// act
const info = parseProjectInformation(env);
// assert
expect(info.homepage).to.be.equal(expected);
});
});
});
function getProcessEnvironmentStub(): NodeJS.ProcessEnv {
return {
VUE_APP_VERSION: 'stub-version',
VUE_APP_NAME: 'stub-name',
VUE_APP_REPOSITORY_URL: 'stub-repository-url',
VUE_APP_HOMEPAGE_URL: 'stub-homepage-url',
};
}

View File

@@ -2,10 +2,11 @@ import 'mocha';
import { expect } from 'chai';
import { parseScript } from '@/application/Parser/ScriptParser';
import { parseDocUrls } from '@/application/Parser/DocumentationParser';
import { RecommendationLevelNames, RecommendationLevel } from '@/domain/RecommendationLevel';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { ScriptCode } from '@/domain/ScriptCode';
import { ScriptCompilerStub } from '../../stubs/ScriptCompilerStub';
import { YamlScriptStub } from '../../stubs/YamlScriptStub';
import { mockEnumParser } from '../../stubs/EnumParserStub';
describe('ScriptParser', () => {
describe('parseScript', () => {
@@ -84,64 +85,28 @@ describe('ScriptParser', () => {
undefinedLevels.forEach((undefinedLevel) => {
// arrange
const compiler = new ScriptCompilerStub();
const script = YamlScriptStub.createWithCode();
script.recommend = undefinedLevel;
const script = YamlScriptStub.createWithCode()
.withRecommend(undefinedLevel);
// act
const actual = parseScript(script, compiler);
// assert
expect(actual.level).to.equal(undefined);
});
});
it('throws on unknown level', () => {
// arrange
const unknownLevel = 'boi';
const compiler = new ScriptCompilerStub();
const script = YamlScriptStub.createWithCode();
script.recommend = unknownLevel;
// act
const act = () => parseScript(script, compiler);
// 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 = YamlScriptStub.createWithCode();
const compiler = new ScriptCompilerStub();
script.recommend = nonStringType;
// act
const act = () => parseScript(script, compiler);
// 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 = YamlScriptStub.createWithCode();
const expectedLevel = RecommendationLevel.Standard;
const expectedName = 'level';
const levelText = 'standard';
const script = YamlScriptStub.createWithCode()
.withRecommend(levelText);
const compiler = new ScriptCompilerStub();
script.recommend = levelText;
const parserMock = mockEnumParser(expectedName, levelText, expectedLevel);
// act
const actual = parseScript(script, compiler);
const actual = parseScript(script, compiler, parserMock);
// assert
expect(actual.level).to.equal(expectedLevel);
});
}
});
it('parses level case insensitive', () => {
// arrange
const script = YamlScriptStub.createWithCode();
const compiler = new ScriptCompilerStub();
const expected = RecommendationLevel.Standard;
script.recommend = RecommendationLevel[expected].toUpperCase();
// act
const actual = parseScript(script, compiler);
// assert
expect(actual.level).to.equal(expected);
});
});
describe('code', () => {
it('parses code as expected', () => {
@@ -196,3 +161,4 @@ describe('ScriptParser', () => {
});
});
});

View File

@@ -0,0 +1,150 @@
import 'mocha';
import { expect } from 'chai';
import { YamlScriptingDefinition } from 'js-yaml-loader!./application.yaml';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { parseScriptingDefinition } from '@/application/Parser/ScriptingDefinitionParser';
import { ProjectInformationStub } from './../../stubs/ProjectInformationStub';
import { mockEnumParser } from '../../stubs/EnumParserStub';
describe('ScriptingDefinitionParser', () => {
describe('parseScriptingDefinition', () => {
it('throws when info is undefined', () => {
// arrange
const info = undefined;
const definition = new ScriptingDefinitionBuilder().construct();
// act
const act = () => parseScriptingDefinition(definition, info);
// assert
expect(act).to.throw('undefined info');
});
it('throws when definition is undefined', () => {
// arrange
const info = new ProjectInformationStub();
const definition = undefined;
// act
const act = () => parseScriptingDefinition(definition, info);
// assert
expect(act).to.throw('undefined definition');
});
describe('language', () => {
it('parses as expected', () => {
// arrange
const expectedLanguage = ScriptingLanguage.batchfile;
const languageText = 'batchfile';
const expectedName = 'language';
const info = new ProjectInformationStub();
const definition = new ScriptingDefinitionBuilder()
.withLanguage(languageText).construct();
const parserMock = mockEnumParser(expectedName, languageText, expectedLanguage);
// act
const actual = parseScriptingDefinition(definition, info, new Date(), parserMock);
// assert
expect(actual.language).to.equal(expectedLanguage);
});
});
describe('fileExtension', () => {
it('parses as expected', () => {
// arrange
const expected = 'bat';
const info = new ProjectInformationStub();
const file = new ScriptingDefinitionBuilder()
.withExtension(expected).construct();
// act
const definition = parseScriptingDefinition(file, info);
// assert
const actual = definition.fileExtension;
expect(actual).to.equal(expected);
});
});
describe('startCode', () => {
it('sets as it is', () => {
// arrange
const expected = 'expected-start-code';
const info = new ProjectInformationStub();
const file = new ScriptingDefinitionBuilder().withStartCode(expected).construct();
// act
const definition = parseScriptingDefinition(file, info);
// assert
expect(definition.startCode).to.equal(expected);
});
it('substitutes as expected', () => {
// arrange
const code = 'homepage: {{ $homepage }}, version: {{ $version }}, date: {{ $date }}';
const homepage = 'https://cloudarchitecture.io';
const version = '1.0.2';
const date = new Date();
const expected = `homepage: ${homepage}, version: ${version}, date: ${date.toUTCString()}`;
const info = new ProjectInformationStub().withHomepageUrl(homepage).withVersion(version);
const file = new ScriptingDefinitionBuilder().withStartCode(code).construct();
// act
const definition = parseScriptingDefinition(file, info, date);
// assert
const actual = definition.startCode;
expect(actual).to.equal(expected);
});
});
describe('endCode', () => {
it('sets as it is', () => {
// arrange
const expected = 'expected-end-code';
const info = new ProjectInformationStub();
const file = new ScriptingDefinitionBuilder().withEndCode(expected).construct();
// act
const definition = parseScriptingDefinition(file, info);
// assert
expect(definition.endCode).to.equal(expected);
});
it('substitutes as expected', () => {
// arrange
const code = 'homepage: {{ $homepage }}, version: {{ $version }}, date: {{ $date }}';
const homepage = 'https://cloudarchitecture.io';
const version = '1.0.2';
const date = new Date();
const expected = `homepage: ${homepage}, version: ${version}, date: ${date.toUTCString()}`;
const info = new ProjectInformationStub().withHomepageUrl(homepage).withVersion(version);
const file = new ScriptingDefinitionBuilder().withEndCode(code).construct();
// act
const definition = parseScriptingDefinition(file, info, date);
// assert
const actual = definition.endCode;
expect(actual).to.equal(expected);
});
});
});
});
class ScriptingDefinitionBuilder {
private language = ScriptingLanguage[ScriptingLanguage.batchfile];
private fileExtension = 'bat';
private startCode = 'startCode';
private endCode = 'endCode';
public withLanguage(language: string): ScriptingDefinitionBuilder {
this.language = language;
return this;
}
public withStartCode(startCode: string): ScriptingDefinitionBuilder {
this.startCode = startCode;
return this;
}
public withEndCode(endCode: string): ScriptingDefinitionBuilder {
this.endCode = endCode;
return this;
}
public withExtension(extension: string): ScriptingDefinitionBuilder {
this.fileExtension = extension;
return this;
}
public construct(): YamlScriptingDefinition {
return {
language: this.language,
fileExtension: this.fileExtension,
startCode: this.startCode,
endCode: this.endCode,
};
}
}

View File

@@ -0,0 +1,84 @@
import { UserSelection } from '@/application/State/Selection/UserSelection';
import { ApplicationCode } from '@/application/State/Code/ApplicationCode';
import { ScriptStub } from './../../stubs/ScriptStub';
import { CategoryStub } from './../../stubs/CategoryStub';
import { ApplicationStub } from './../../stubs/ApplicationStub';
import 'mocha';
import { expect } from 'chai';
import { ApplicationState } from '@/application/State/ApplicationState';
import { IScript } from '@/domain/IScript';
describe('ApplicationState', () => {
describe('code', () => {
it('initialized with empty code', () => {
// arrange
const app = new ApplicationStub();
const sut = new ApplicationState(app);
// act
const code = sut.code.current;
// assert
expect(!code);
});
it('reacts to selection changes as expected', () => {
// arrange
const app = new ApplicationStub().withAction(new CategoryStub(0).withScriptIds('scriptId'));
const selectionStub = new UserSelection(app, []);
const expectedCodeGenerator = new ApplicationCode(selectionStub, app.scripting);
selectionStub.selectAll();
const expectedCode = expectedCodeGenerator.current;
// act
const sut = new ApplicationState(app);
sut.selection.selectAll();
const actualCode = sut.code.current;
// assert
expect(actualCode).to.equal(expectedCode);
});
});
describe('selection', () => {
it('initialized with no selection', () => {
// arrange
const app = new ApplicationStub();
const sut = new ApplicationState(app);
// act
const actual = sut.selection.totalSelected;
// assert
expect(actual).to.equal(0);
});
it('can select a script from current application', () => {
// arrange
const expectedScript = new ScriptStub('scriptId');
const app = new ApplicationStub().withAction(new CategoryStub(0).withScript(expectedScript));
const sut = new ApplicationState(app);
// act
sut.selection.selectAll();
// assert
expect(sut.selection.totalSelected).to.equal(1);
expect(sut.selection.isSelected(expectedScript.id)).to.equal(true);
});
});
describe('filter', () => {
it('initialized with an empty filter', () => {
// arrange
const app = new ApplicationStub();
const sut = new ApplicationState(app);
// act
const actual = sut.filter.currentFilter;
// assert
expect(actual).to.equal(undefined);
});
it('can match a script from current application', () => {
// arrange
const scriptNameFilter = 'scriptName';
const expectedScript = new ScriptStub('scriptId').withName(scriptNameFilter);
const app = new ApplicationStub()
.withAction(new CategoryStub(0).withScript(expectedScript));
const sut = new ApplicationState(app);
// act
let actualScript: IScript;
sut.filter.filtered.on((result) => actualScript = result.scriptMatches[0]);
sut.filter.setFilter(scriptNameFilter);
// assert
expect(expectedScript).to.equal(actualScript);
});
});
});

View File

@@ -10,29 +10,41 @@ import { ICodeChangedEvent } from '@/application/State/Code/Event/ICodeChangedEv
import { IUserScriptGenerator } from '@/application/State/Code/Generation/IUserScriptGenerator';
import { CodePosition } from '@/application/State/Code/Position/CodePosition';
import { ICodePosition } from '@/application/State/Code/Position/ICodePosition';
import { ScriptingDefinitionStub } from './../../../stubs/ScriptingDefinitionStub';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { IUserScript } from '@/application/State/Code/Generation/IUserScript';
// TODO: Test scriptingDefinition: IScriptingDefinition logic
describe('ApplicationCode', () => {
describe('ctor', () => {
it('empty when selection is empty', () => {
// arrange
const selection = new UserSelection(new ApplicationStub(), []);
const sut = new ApplicationCode(selection, 'version');
const definition = new ScriptingDefinitionStub();
const sut = new ApplicationCode(selection, definition);
// act
const actual = sut.current;
// assert
expect(actual).to.have.lengthOf(0);
});
it('has code when selection is not empty', () => {
it('generates code from script generator when selection is not empty', () => {
// arrange
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const app = new ApplicationStub().withAction(new CategoryStub(1).withScripts(...scripts));
const selection = new UserSelection(app, scripts.map((script) => new SelectedScript(script, false)));
const version = 'version-string';
const sut = new ApplicationCode(selection, version);
const selection = new UserSelection(app, scripts.map((script) => script.toSelectedScript()));
const definition = new ScriptingDefinitionStub();
const expected: IUserScript = {
code: 'expected-code',
scriptPositions: new Map(),
};
const generator = new UserScriptGeneratorMock()
.plan({ scripts: selection.selectedScripts, definition }, expected);
const sut = new ApplicationCode(selection, definition, generator);
// act
const actual = sut.current;
// assert
expect(actual).to.have.length.greaterThan(0).and.include(version);
expect(actual).to.equal(expected.code);
});
});
describe('changed event', () => {
@@ -43,7 +55,8 @@ describe('ApplicationCode', () => {
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const app = new ApplicationStub().withAction(new CategoryStub(1).withScripts(...scripts));
const selection = new UserSelection(app, scripts.map((script) => new SelectedScript(script, false)));
const sut = new ApplicationCode(selection, 'version');
const definition = new ScriptingDefinitionStub();
const sut = new ApplicationCode(selection, definition);
sut.changed.on((code) => signaled = code);
// act
selection.changed.notify([]);
@@ -57,23 +70,72 @@ describe('ApplicationCode', () => {
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const app = new ApplicationStub().withAction(new CategoryStub(1).withScripts(...scripts));
const selection = new UserSelection(app, scripts.map((script) => new SelectedScript(script, false)));
const version = 'version-string';
const sut = new ApplicationCode(selection, version);
const definition = new ScriptingDefinitionStub();
const sut = new ApplicationCode(selection, definition);
sut.changed.on((code) => signaled = code);
// act
selection.changed.notify(scripts.map((s) => new SelectedScript(s, false)));
// assert
expect(signaled.code).to.have.length.greaterThan(0).and.include(version);
expect(signaled.code).to.have.length.greaterThan(0);
expect(signaled.code).to.equal(sut.current);
});
});
describe('calls UserScriptGenerator', () => {
it('sends scripting definition to generator', () => {
// arrange
const expectedDefinition = new ScriptingDefinitionStub();
const app = new ApplicationStub();
const selection = new UserSelection(app, []);
const generatorMock: IUserScriptGenerator = {
buildCode: (selectedScripts, definition) => {
if (definition !== expectedDefinition) {
throw new Error('Unexpected scripting definition');
}
return {
code: '',
scriptPositions: new Map<SelectedScript, ICodePosition>(),
};
},
};
// tslint:disable-next-line:no-unused-expression
new ApplicationCode(selection, expectedDefinition, generatorMock);
// act
const act = () => selection.changed.notify([]);
// assert
expect(act).to.not.throw();
});
it('sends selected scripts to generator', () => {
// arrange
const expectedDefinition = new ScriptingDefinitionStub();
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const app = new ApplicationStub().withAction(new CategoryStub(1).withScripts(...scripts));
const selection = new UserSelection(app, scripts.map((script) => new SelectedScript(script, false)));
const scriptsToSelect = scripts.map((s) => new SelectedScript(s, false));
const generatorMock: IUserScriptGenerator = {
buildCode: (selectedScripts) => {
if (JSON.stringify(selectedScripts) !== JSON.stringify(scriptsToSelect)) {
throw new Error('Unexpected scripts');
}
return {
code: '',
scriptPositions: new Map<SelectedScript, ICodePosition>(),
};
},
};
// tslint:disable-next-line:no-unused-expression
new ApplicationCode(selection, expectedDefinition, generatorMock);
// act
const act = () => selection.changed.notify(scriptsToSelect);
// assert
expect(act).to.not.throw();
});
it('sets positions from the generator', () => {
// arrange
let signaled: ICodeChangedEvent;
const scripts = [new ScriptStub('first'), new ScriptStub('second')];
const app = new ApplicationStub().withAction(new CategoryStub(1).withScripts(...scripts));
const selection = new UserSelection(app, scripts.map((script) => new SelectedScript(script, false)));
const expectedVersion = 'version-string';
const scriptingDefinition = new ScriptingDefinitionStub();
const scriptsToSelect = scripts.map((s) => new SelectedScript(s, false));
const totalLines = 20;
const expected = new Map<SelectedScript, ICodePosition>(
@@ -83,20 +145,14 @@ describe('ApplicationCode', () => {
],
);
const generatorMock: IUserScriptGenerator = {
buildCode: (selectedScripts, version) => {
if (version !== expectedVersion) {
throw new Error('Unexpected version');
}
if (JSON.stringify(selectedScripts) !== JSON.stringify(scriptsToSelect)) {
throw new Error('Unexpected scripts');
}
buildCode: () => {
return {
code: '\nREM LINE'.repeat(totalLines),
scriptPositions: expected,
};
},
};
const sut = new ApplicationCode(selection, expectedVersion, generatorMock);
const sut = new ApplicationCode(selection, scriptingDefinition, generatorMock);
sut.changed.on((code) => signaled = code);
// act
selection.changed.notify(scriptsToSelect);
@@ -108,3 +164,29 @@ describe('ApplicationCode', () => {
});
});
});
});
interface IScriptGenerationParameters {
scripts: readonly SelectedScript[];
definition: IScriptingDefinition;
}
class UserScriptGeneratorMock implements IUserScriptGenerator {
private prePlanned = new Map<IScriptGenerationParameters, IUserScript>();
public plan(
parameters: IScriptGenerationParameters,
result: IUserScript): UserScriptGeneratorMock {
this.prePlanned.set(parameters, result);
return this;
}
public buildCode(
selectedScripts: readonly SelectedScript[],
scriptingDefinition: IScriptingDefinition): IUserScript {
for (const [parameters, result] of Array.from(this.prePlanned)) {
if (selectedScripts === parameters.scripts
&& scriptingDefinition === parameters.definition) {
return result;
}
}
throw new Error('Unexpected parameters');
}
}

View File

@@ -1,40 +1,98 @@
import { ScriptStub } from '../../../../stubs/ScriptStub';
import { UserScriptGenerator, adminRightsScript } from '@/application/State/Code/Generation/UserScriptGenerator';
import 'mocha';
import { expect } from 'chai';
import { UserScriptGenerator } from '@/application/State/Code/Generation/UserScriptGenerator';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { SelectedScriptStub } from '../../../../stubs/SelectedScriptStub';
import { CodeBuilder } from '@/application/State/Code/Generation/CodeBuilder';
import { ScriptStub } from '../../../../stubs/ScriptStub';
import { ScriptingDefinitionStub } from '../../../../stubs/ScriptingDefinitionStub';
describe('UserScriptGenerator', () => {
it('adds version', () => {
describe('scriptingDefinition', () => {
describe('startCode', () => {
it('is prepended if not empty', () => {
// arrange
const sut = new UserScriptGenerator();
const version = '1.5.0';
const selectedScripts = [ new SelectedScript(new ScriptStub('id'), false)];
const startCode = 'Start\nCode';
const script = new ScriptStub('id')
.withCode('code\nmulti-lined')
.toSelectedScript();
const definition = new ScriptingDefinitionStub()
.withStartCode(startCode)
.withEndCode(undefined);
const expectedStart = `${startCode}\n`;
// act
const actual = sut.buildCode(selectedScripts, version);
const code = sut.buildCode([script], definition);
// assert
expect(actual.code).to.include(version);
const actual = code.code;
expect(actual.startsWith(expectedStart));
});
it('adds admin rights function', () => {
it('is not prepended if empty', () => {
// arrange
const sut = new UserScriptGenerator();
const selectedScripts = [ new SelectedScript(new ScriptStub('id'), false)];
const script = new ScriptStub('id')
.withCode('code\nmulti-lined')
.toSelectedScript();
const definition = new ScriptingDefinitionStub()
.withStartCode(undefined)
.withEndCode(undefined);
const expectedStart = new CodeBuilder()
.appendFunction(script.script.name, script.script.code.execute)
.toString();
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
const code = sut.buildCode([script], definition);
// assert
expect(actual.code).to.include(adminRightsScript.code);
expect(actual.code).to.include(adminRightsScript.name);
const actual = code.code;
expect(actual.startsWith(expectedStart));
});
});
describe('endCode', () => {
it('is appended if not empty', () => {
// arrange
const sut = new UserScriptGenerator();
const endCode = 'End\nCode';
const script = new ScriptStub('id')
.withCode('code\nmulti-lined')
.toSelectedScript();
const definition = new ScriptingDefinitionStub()
.withEndCode(endCode);
const expectedEnd = `${endCode}\n`;
// act
const code = sut.buildCode([script], definition);
// assert
const actual = code.code;
expect(actual.endsWith(expectedEnd));
});
it('is not appended if empty', () => {
// arrange
const sut = new UserScriptGenerator();
const script = new ScriptStub('id')
.withCode('code\nmulti-lined')
.toSelectedScript();
const definition = new ScriptingDefinitionStub()
.withEndCode(undefined);
const expectedEnd = new CodeBuilder()
.appendFunction(script.script.name, script.script.code.execute)
.toString();
// act
const code = sut.buildCode([script], definition);
// assert
const actual = code.code;
expect(actual.endsWith(expectedEnd));
});
});
});
it('appends revert script', () => {
// arrange
const sut = new UserScriptGenerator();
const scriptName = 'test non-revert script';
const scriptCode = 'REM nop';
const script = new ScriptStub('id').withName(scriptName).withRevertCode(scriptCode);
const selectedScripts = [ new SelectedScript(script, true)];
const script = new ScriptStub('id')
.withName(scriptName)
.withRevertCode(scriptCode)
.toSelectedScript(true);
const definition = new ScriptingDefinitionStub();
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
const actual = sut.buildCode([ script ], definition);
// assert
expect(actual.code).to.include(`${scriptName} (revert)`);
expect(actual.code).to.include(scriptCode);
@@ -46,49 +104,98 @@ describe('UserScriptGenerator', () => {
const scriptCode = 'REM nop';
const script = new ScriptStub('id').withName(scriptName).withCode(scriptCode);
const selectedScripts = [ new SelectedScript(script, false)];
const definition = new ScriptingDefinitionStub();
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
const actual = sut.buildCode(selectedScripts, definition);
// assert
expect(actual.code).to.include(scriptName);
expect(actual.code).to.not.include(`${scriptName} (revert)`);
expect(actual.code).to.include(scriptCode);
});
describe('scriptPositions', () => {
it('single script', () => {
it('without script; returns empty', () => {
// arrange
const sut = new UserScriptGenerator();
const scriptName = 'test non-revert script';
const scriptCode = 'REM nop\nREM nop2';
const script = new ScriptStub('id').withName(scriptName).withCode(scriptCode);
const selectedScripts = [ new SelectedScript(script, false)];
const selectedScripts = [ ];
const definition = new ScriptingDefinitionStub();
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
const actual = sut.buildCode(selectedScripts, definition);
// assert
expect(actual.scriptPositions.size).to.equal(1);
const position = actual.scriptPositions.get(selectedScripts[0]);
expect(position.endLine).to.be.greaterThan(position.startLine + 2);
expect(actual.scriptPositions.size).to.equal(0);
});
describe('with scripts', () => {
// arrange
const totalStartCodeLines = 2;
const totalFunctionNameLines = 4;
const definition = new ScriptingDefinitionStub()
.withStartCode('First line\nSecond line');
describe('single script', () => {
const testCases = [
{
name: 'single-lined',
scriptCode: 'only line',
codeLines: 1,
},
{
name: 'multi-lined',
scriptCode: 'first line\nsecond line',
codeLines: 2,
},
];
const sut = new UserScriptGenerator();
for (const testCase of testCases) {
it(testCase.name, () => {
const expectedStartLine = totalStartCodeLines
+ 1 // empty line code begin
+ 1; // code begin
const expectedEndLine = expectedStartLine
+ totalFunctionNameLines
+ testCase.codeLines;
const selectedScript = new ScriptStub(`script-id`)
.withName(`script`)
.withCode(testCase.scriptCode)
.toSelectedScript(false);
// act
const actual = sut.buildCode([ selectedScript ], definition);
// expect
expect(1).to.equal(actual.scriptPositions.size);
const position = actual.scriptPositions.get(selectedScript);
expect(expectedStartLine).to.equal(position.startLine, 'Unexpected start line position');
expect(expectedEndLine).to.equal(position.endLine, 'Unexpected end line position');
});
}
});
it('multiple scripts', () => {
// arrange
const sut = new UserScriptGenerator();
const selectedScripts = [ new SelectedScriptStub('1'), new SelectedScriptStub('2') ];
const selectedScripts = [
new ScriptStub('1').withCode('only line'),
new ScriptStub('2').withCode('first line\nsecond line'),
].map((s) => s.toSelectedScript());
const expectedFirstScriptStart = totalStartCodeLines
+ 1 // empty line code begin
+ 1; // code begin
const expectedFirstScriptEnd = expectedFirstScriptStart
+ totalFunctionNameLines
+ 1; // total code lines
const expectedSecondScriptStart = expectedFirstScriptEnd
+ 1 // code end hyphens
+ 1 // new line
+ 1; // code begin
const expectedSecondScriptEnd =
expectedSecondScriptStart
+ totalFunctionNameLines
+ 2; // total lines of second script
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
const actual = sut.buildCode(selectedScripts, definition);
// assert
const firstPosition = actual.scriptPositions.get(selectedScripts[0]);
const secondPosition = actual.scriptPositions.get(selectedScripts[1]);
expect(actual.scriptPositions.size).to.equal(2);
expect(firstPosition.endLine).to.be.greaterThan(firstPosition.startLine + 1);
expect(secondPosition.startLine).to.be.greaterThan(firstPosition.endLine);
expect(secondPosition.endLine).to.be.greaterThan(secondPosition.startLine + 1);
});
it('no script', () => {
// arrange
const sut = new UserScriptGenerator();
const selectedScripts = [ ];
// act
const actual = sut.buildCode(selectedScripts, 'non-important-version');
// assert
expect(actual.scriptPositions.size).to.equal(0);
expect(expectedFirstScriptStart).to.equal(firstPosition.startLine, 'Unexpected start line position (first script)');
expect(expectedFirstScriptEnd).to.equal(firstPosition.endLine, 'Unexpected end line position (first script)');
expect(expectedSecondScriptStart).to.equal(secondPosition.startLine, 'Unexpected start line position (second script)');
expect(expectedSecondScriptEnd).to.equal(secondPosition.endLine, 'Unexpected end line position (second script)');
});
});
});
});

View File

@@ -5,21 +5,27 @@ import 'mocha';
import { expect } from 'chai';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { IProjectInformation } from '@/domain/IProjectInformation';
import { RecommendationLevel, RecommendationLevels } from '@/domain/RecommendationLevel';
import { ICategory } from '@/domain/IApplication';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { getEnumValues } from '@/application/Common/Enum';
describe('Application', () => {
describe('getScriptsByLevel', () => {
it('filters out scripts without levels', () => {
// arrange
const scriptsWithLevels = RecommendationLevels.map((level, index) =>
const recommendationLevels = getEnumValues(RecommendationLevel);
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) {
for (const currentLevel of recommendationLevels) {
const category = new CategoryStub(0)
.withScripts(...scriptsWithLevels)
.withScript(toIgnore);
const sut = new Application(createInformation(), [category]);
const sut = new ApplicationBuilder().withActions([category]).construct();
// act
const actual = sut.getScriptsByLevel(currentLevel);
// assert
@@ -33,10 +39,11 @@ describe('Application', () => {
new ScriptStub('S1').withLevel(level),
new ScriptStub('S2').withLevel(level),
];
const sut = new Application(createInformation(), [
const actions = [
new CategoryStub(3).withScripts(...expected,
new ScriptStub('S3').withLevel(RecommendationLevel.Strict)),
]);
];
const sut = new ApplicationBuilder().withActions(actions).construct();
// act
const actual = sut.getScriptsByLevel(level);
// assert
@@ -49,9 +56,10 @@ describe('Application', () => {
new ScriptStub('S1').withLevel(RecommendationLevel.Standard),
new ScriptStub('S2').withLevel(RecommendationLevel.Strict),
];
const sut = new Application(createInformation(), [
const actions = [
new CategoryStub(3).withScripts(...expected),
]);
];
const sut = new ApplicationBuilder().withActions(actions).construct();
// act
const actual = sut.getScriptsByLevel(level);
// assert
@@ -59,7 +67,7 @@ describe('Application', () => {
});
it('throws when level is undefined', () => {
// arrange
const sut = new Application(createInformation(), [ getCategoryForValidApplication() ]);
const sut = new ApplicationBuilder().construct();
// act
const act = () => sut.getScriptsByLevel(undefined);
// assert
@@ -68,21 +76,21 @@ describe('Application', () => {
it('throws when level is out of range', () => {
// arrange
const invalidValue = 66;
const sut = new Application(createInformation(), [
getCategoryForValidApplication(),
]);
const sut = new ApplicationBuilder().construct();
// act
const act = () => sut.getScriptsByLevel(invalidValue);
// assert
expect(act).to.throw(`invalid level: ${invalidValue}`);
});
});
describe('ctor', () => {
it('cannot construct without categories', () => {
describe('actions', () => {
it('cannot construct without actions', () => {
// arrange
const categories = [];
// act
function construct() { return new Application(createInformation(), categories); }
function construct() {
new ApplicationBuilder().withActions(categories).construct();
}
// assert
expect(construct).to.throw('Application must consist of at least one category');
});
@@ -93,33 +101,31 @@ describe('Application', () => {
new CategoryStub(2),
];
// act
function construct() { return new Application(createInformation(), categories); }
function construct() {
new ApplicationBuilder().withActions(categories).construct();
}
// assert
expect(construct).to.throw('Application must consist of at least one script');
});
describe('cannot construct without any recommended scripts', () => {
for (const missingLevel of RecommendationLevels) {
// arrange
const recommendationLevels = getEnumValues(RecommendationLevel);
for (const missingLevel of recommendationLevels) {
it(`when "${RecommendationLevel[missingLevel]}" is missing`, () => {
const expectedError = `none of the scripts are recommended as ${RecommendationLevel[missingLevel]}`;
const otherLevels = RecommendationLevels.filter((level) => level !== missingLevel);
const otherLevels = recommendationLevels.filter((level) => level !== missingLevel);
const categories = otherLevels.map((level, index) =>
new CategoryStub(index).withScript(new ScriptStub(`Script${index}`).withLevel(level)),
);
new CategoryStub(index).withScript(
new ScriptStub(`Script${index}`).withLevel(level),
));
// act
const construct = () => new Application(createInformation(), categories);
const construct = () => new ApplicationBuilder()
.withActions(categories)
.construct();
// assert
expect(construct).to.throw(expectedError);
}
});
it('cannot construct without information', () => {
// arrange
const categories = [ new CategoryStub(1).withScripts(
new ScriptStub('S1').withLevel(RecommendationLevel.Standard))];
const information = undefined;
// act
function construct() { return new Application(information, categories); }
// assert
expect(construct).to.throw('info is undefined');
}
});
});
describe('totalScripts', () => {
@@ -135,7 +141,7 @@ describe('Application', () => {
new CategoryStub(4).withScripts(new ScriptStub('S4'))),
];
// act
const sut = new Application(createInformation(), categories);
const sut = new ApplicationBuilder().withActions(categories).construct();
// assert
expect(sut.totalScripts).to.equal(4);
});
@@ -143,36 +149,132 @@ describe('Application', () => {
describe('totalCategories', () => {
it('returns total of initial categories', () => {
// arrange
const expected = 4;
const categories = [
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'))),
];
// act
const sut = new Application(createInformation(), categories);
const sut = new ApplicationBuilder()
.withActions(categories)
.construct();
// assert
expect(sut.totalCategories).to.equal(4);
expect(sut.totalCategories).to.equal(expected);
});
});
describe('info', () => {
it('returns initial information', () => {
describe('information', () => {
it('sets information as expected', () => {
// arrange
const expected = createInformation();
const expected = new ProjectInformation(
'expected-name', 'expected-repo', '0.31.0', 'expected-homepage');
// act
const sut = new Application(
expected, [ getCategoryForValidApplication() ]);
const sut = new ApplicationBuilder().withInfo(expected).construct();
// assert
expect(sut.info).to.deep.equal(expected);
});
it('cannot construct without information', () => {
// arrange
const information = undefined;
// act
function construct() {
return new ApplicationBuilder().withInfo(information).construct();
}
// assert
expect(construct).to.throw('undefined info');
});
});
describe('os', () => {
it('sets os as expected', () => {
// arrange
const expected = OperatingSystem.macOS;
// act
const sut = new ApplicationBuilder().withOs(expected).construct();
// assert
expect(sut.os).to.deep.equal(expected);
});
it('cannot construct with unknown os', () => {
// arrange
const os = OperatingSystem.Unknown;
// act
const construct = () => new ApplicationBuilder().withOs(os).construct();
// assert
expect(construct).to.throw('unknown os');
});
it('cannot construct with undefined os', () => {
// arrange
const os = undefined;
// act
const construct = () => new ApplicationBuilder().withOs(os).construct();
// assert
expect(construct).to.throw('undefined os');
});
it('cannot construct with OS not in range', () => {
// arrange
const os: OperatingSystem = 666;
// act
const construct = () => new ApplicationBuilder().withOs(os).construct();
// assert
expect(construct).to.throw(`os "${os}" is out of range`);
});
});
describe('scriptingDefinition', () => {
it('sets scriptingDefinition as expected', () => {
// arrange
const expected = getValidScriptingDefinition();
// act
const sut = new ApplicationBuilder().withScripting(expected).construct();
// assert
expect(sut.scripting).to.deep.equal(expected);
});
it('cannot construct without initial script', () => {
// arrange
const scriptingDefinition = undefined;
// act
function construct() {
return new ApplicationBuilder().withScripting(scriptingDefinition).construct();
}
// assert
expect(construct).to.throw('undefined scripting definition');
});
});
});
function getCategoryForValidApplication() {
return new CategoryStub(1).withScripts(
function getValidScriptingDefinition(): IScriptingDefinition {
return {
fileExtension: '.bat',
language: ScriptingLanguage.batchfile,
startCode: 'start',
endCode: 'end',
};
}
class ApplicationBuilder {
private os = OperatingSystem.Windows;
private info = new ProjectInformation('name', 'repo', '0.1.0', 'homepage');
private actions: readonly ICategory[] = [
new CategoryStub(1).withScripts(
new ScriptStub('S1').withLevel(RecommendationLevel.Standard),
new ScriptStub('S2').withLevel(RecommendationLevel.Strict));
new ScriptStub('S2').withLevel(RecommendationLevel.Strict)),
];
private script: IScriptingDefinition = getValidScriptingDefinition();
public withOs(os: OperatingSystem): ApplicationBuilder {
this.os = os;
return this;
}
public withInfo(info: IProjectInformation) {
this.info = info;
return this;
}
public withActions(actions: readonly ICategory[]) {
this.actions = actions;
return this;
}
public withScripting(script: IScriptingDefinition) {
this.script = script;
return this;
}
public construct(): Application {
return new Application(this.os, this.info, this.actions, this.script);
}
function createInformation(): IProjectInformation {
return new ProjectInformation('name', 'repo', '0.1.0', 'homepage');
}

View File

@@ -1,17 +0,0 @@
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,7 +1,8 @@
import { getEnumValues } from '@/application/Common/Enum';
import 'mocha';
import { expect } from 'chai';
import { Script } from '@/domain/Script';
import { RecommendationLevel, RecommendationLevels } from '@/domain/RecommendationLevel';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { ScriptCode } from '@/domain/ScriptCode';
import { IScriptCode } from '@/domain/IScriptCode';
@@ -80,7 +81,7 @@ describe('Script', () => {
});
it('sets as expected', () => {
// arrange
for (const expected of RecommendationLevels) {
for (const expected of getEnumValues(RecommendationLevel)) {
// act
const sut = new ScriptBuilder()
.withRecommendationLevel(expected)

View File

@@ -0,0 +1,134 @@
import 'mocha';
import { expect } from 'chai';
import { ScriptingDefinition } from '@/domain/ScriptingDefinition';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { getEnumValues } from '@/application/Common/Enum';
import { OperatingSystem } from '@/domain/OperatingSystem';
describe('ScriptingDefinition', () => {
describe('language', () => {
describe('sets as expected', () => {
// arrange
const expectedValues = getEnumValues(ScriptingLanguage);
expectedValues.forEach((expected) => {
it(ScriptingLanguage[expected], () => {
// act
const sut = new ScriptingDefinitionBuilder()
.withLanguage(expected)
.build();
// assert
expect(sut.language).to.equal(expected);
});
});
});
it('throws if unknown', () => {
// arrange
const unknownValue: ScriptingLanguage = 666;
const errorMessage = `unsupported language: ${unknownValue}`;
// act
const act = () => new ScriptingDefinitionBuilder()
.withLanguage(unknownValue)
.build();
// assert
expect(act).to.throw(errorMessage);
});
});
describe('fileExtension', () => {
describe('returns expected for each language', () => {
// arrange
const testCases = new Map<ScriptingLanguage, string>([
[ScriptingLanguage.batchfile, 'bat'],
[ScriptingLanguage.bash, 'sh'],
]);
Array.from(testCases.entries()).forEach((test) => {
const language = test[0];
const expectedExtension = test[1];
it(`${ScriptingLanguage[language]} has ${expectedExtension}`, () => {
// act
const sut = new ScriptingDefinitionBuilder()
.withLanguage(language)
.build();
// assert
expect(sut.fileExtension, expectedExtension);
});
});
});
});
describe('startCode', () => {
it('sets as expected', () => {
// arrange
const expected = 'REM start-code';
// act
const sut = new ScriptingDefinitionBuilder()
.withStartCode(expected)
.build();
// assert
expect(sut.startCode).to.equal(expected);
});
it('throws when undefined', () => {
// arrange
const expectedError = 'undefined start code';
const undefinedValues = [ '', undefined ];
for (const undefinedValue of undefinedValues) {
// act
const act = () => new ScriptingDefinitionBuilder()
.withStartCode(undefinedValue)
.build();
// assert
expect(act).to.throw(expectedError);
}
});
});
describe('endCode', () => {
it('sets as expected', () => {
// arrange
const expected = 'REM end-code';
// act
const sut = new ScriptingDefinitionBuilder()
.withEndCode(expected)
.build();
// assert
expect(sut.endCode).to.equal(expected);
});
it('throws when undefined', () => {
// arrange
const expectedError = 'undefined end code';
const undefinedValues = [ '', undefined ];
for (const undefinedValue of undefinedValues) {
// act
const act = () => new ScriptingDefinitionBuilder()
.withEndCode(undefinedValue)
.build();
// assert
expect(act).to.throw(expectedError);
}
});
});
});
class ScriptingDefinitionBuilder {
private language = ScriptingLanguage.bash;
private startCode = 'REM start-code';
private endCode = 'REM end-code';
public withLanguage(language: ScriptingLanguage): ScriptingDefinitionBuilder {
this.language = language;
return this;
}
public withStartCode(startCode: string): ScriptingDefinitionBuilder {
this.startCode = startCode;
return this;
}
public withEndCode(endCode: string): ScriptingDefinitionBuilder {
this.endCode = endCode;
return this;
}
public build(): ScriptingDefinition {
return new ScriptingDefinition(
this.language, this.startCode, this.endCode);
}
}

View File

@@ -1,7 +1,14 @@
import { ScriptingDefinitionStub } from './ScriptingDefinitionStub';
import { IApplication, ICategory, IScript } from '@/domain/IApplication';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ScriptStub } from './ScriptStub';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
export class ApplicationStub implements IApplication {
public scripting: IScriptingDefinition = new ScriptingDefinitionStub();
public os = OperatingSystem.Linux;
public initialScript: IScript = new ScriptStub('55');
public totalScripts = 0;
public totalCategories = 0;
public readonly info = new ProjectInformation('StubApplication', '0.1.0', 'https://github.com/undergroundwires/privacy.sexy', 'https://privacy.sexy');
@@ -11,6 +18,18 @@ export class ApplicationStub implements IApplication {
this.actions.push(category);
return this;
}
public withOs(os: OperatingSystem): ApplicationStub {
this.os = os;
return this;
}
public withScripting(scripting: IScriptingDefinition): ApplicationStub {
this.scripting = scripting;
return this;
}
public withInitialScript(script: IScript): ApplicationStub {
this.initialScript = script;
return this;
}
public findCategory(categoryId: number): ICategory {
return this.getAllCategories().find(
(category) => category.id === categoryId);

View File

@@ -0,0 +1,15 @@
import { IEnumParser } from '@/application/Common/Enum';
export function mockEnumParser<T>(inputName: string, inputValue: string, outputValue: T): IEnumParser<T> {
return {
parseEnum: (value, name) => {
if (name !== inputName) {
throw new Error(`Unexpected name: "${name}"`);
}
if (value !== inputValue) {
throw new Error(`Unexpected value: "${value}"`);
}
return outputValue;
},
};
}

View File

@@ -0,0 +1,43 @@
import { IProjectInformation } from '@/domain/IProjectInformation';
import { OperatingSystem } from '@/domain/OperatingSystem';
export class ProjectInformationStub implements IProjectInformation {
public name: string;
public version: string;
public repositoryUrl: string;
public homepage: string;
public feedbackUrl: string;
public releaseUrl: string;
public repositoryWebUrl: string;
public withName(name: string): ProjectInformationStub {
this.name = name;
return this;
}
public withVersion(version: string): ProjectInformationStub {
this.version = version;
return this;
}
public withRepositoryUrl(repositoryUrl: string): ProjectInformationStub {
this.repositoryUrl = repositoryUrl;
return this;
}
public withHomepageUrl(homepageUrl: string): ProjectInformationStub {
this.homepage = homepageUrl;
return this;
}
public withFeedbackUrl(feedbackUrl: string): ProjectInformationStub {
this.feedbackUrl = feedbackUrl;
return this;
}
public withReleaseUrl(releaseUrl: string): ProjectInformationStub {
this.releaseUrl = releaseUrl;
return this;
}
public withRepositoryWebUrl(repositoryWebUrl: string): ProjectInformationStub {
this.repositoryWebUrl = repositoryWebUrl;
return this;
}
public getDownloadUrl(os: OperatingSystem): string {
throw new Error('Method not implemented.');
}
}

View File

@@ -1,6 +1,7 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import { IScript } from '@/domain/IScript';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
export class ScriptStub extends BaseEntity<string> implements IScript {
public name = `name${this.id}`;
@@ -38,4 +39,8 @@ export class ScriptStub extends BaseEntity<string> implements IScript {
this.code.revert = revertCode;
return this;
}
public toSelectedScript(isReverted = false): SelectedScript {
return new SelectedScript(this, isReverted);
}
}

View File

@@ -0,0 +1,18 @@
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
export class ScriptingDefinitionStub implements IScriptingDefinition {
public fileExtension: string = '.bat';
public language = ScriptingLanguage.batchfile;
public startCode = 'REM start code';
public endCode = 'REM end code';
public withStartCode(startCode: string): ScriptingDefinitionStub {
this.startCode = startCode;
return this;
}
public withEndCode(endCode: string): ScriptingDefinitionStub {
this.endCode = endCode;
return this;
}
}

View File

@@ -58,4 +58,10 @@ export class YamlScriptStub implements YamlScript {
this.call = call;
return this;
}
public withRecommend(recommend: string): YamlScriptStub {
this.recommend = recommend;
return this;
}
}