diff --git a/src/application/State/Code/ApplicationCode.ts b/src/application/State/Code/ApplicationCode.ts index 27ca3787..9476333e 100644 --- a/src/application/State/Code/ApplicationCode.ts +++ b/src/application/State/Code/ApplicationCode.ts @@ -1,30 +1,38 @@ +import { CodeChangedEvent } from './Event/CodeChangedEvent'; +import { CodePosition } from './Position/CodePosition'; +import { ICodeChangedEvent } from './Event/ICodeChangedEvent'; import { SelectedScript } from '@/application/State/Selection/SelectedScript'; import { IUserSelection } from '@/application/State/Selection/IUserSelection'; -import { UserScriptGenerator } from './UserScriptGenerator'; +import { UserScriptGenerator } from './Generation/UserScriptGenerator'; import { Signal } from '@/infrastructure/Events/Signal'; import { IApplicationCode } from './IApplicationCode'; -import { IUserScriptGenerator } from './IUserScriptGenerator'; +import { IUserScriptGenerator } from './Generation/IUserScriptGenerator'; export class ApplicationCode implements IApplicationCode { - public readonly changed = new Signal(); + public readonly changed = new Signal(); public current: string; - private readonly generator: IUserScriptGenerator = new UserScriptGenerator(); + private scriptPositions = new Map(); constructor( userSelection: IUserSelection, - private readonly version: string) { + private readonly version: string, + 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'); } - this.generator = new UserScriptGenerator(); + if (!generator) { throw new Error('generator is null or undefined'); } this.setCode(userSelection.selectedScripts); userSelection.changed.on((scripts) => { this.setCode(scripts); }); } - private setCode(scripts: ReadonlyArray) { - this.current = scripts.length === 0 ? '' : this.generator.buildCode(scripts, this.version); - this.changed.notify(this.current); + private setCode(scripts: ReadonlyArray): void { + const oldScripts = Array.from(this.scriptPositions.keys()); + const code = this.generator.buildCode(scripts, this.version); + this.current = code.code; + this.scriptPositions = code.scriptPositions; + const event = new CodeChangedEvent(code.code, oldScripts, code.scriptPositions); + this.changed.notify(event); } } diff --git a/src/application/State/Code/Event/CodeChangedEvent.ts b/src/application/State/Code/Event/CodeChangedEvent.ts new file mode 100644 index 00000000..6ab3d58c --- /dev/null +++ b/src/application/State/Code/Event/CodeChangedEvent.ts @@ -0,0 +1,64 @@ +import { ICodeChangedEvent } from './ICodeChangedEvent'; +import { SelectedScript } from '../../Selection/SelectedScript'; +import { IScript } from '@/domain/IScript'; +import { ICodePosition } from '@/application/State/Code/Position/ICodePosition'; + +export class CodeChangedEvent implements ICodeChangedEvent { + public readonly code: string; + public readonly addedScripts: ReadonlyArray; + public readonly removedScripts: ReadonlyArray; + public readonly changedScripts: ReadonlyArray; + + private readonly scripts: Map; + + constructor( + code: string, + oldScripts: ReadonlyArray, + scripts: Map) { + ensureAllPositionsExist(code, Array.from(scripts.values())); + this.code = code; + const newScripts = Array.from(scripts.keys()); + this.addedScripts = selectIfNotExists(newScripts, oldScripts); + this.removedScripts = selectIfNotExists(oldScripts, newScripts); + this.changedScripts = getChangedScripts(oldScripts, newScripts); + this.scripts = new Map(); + scripts.forEach((position, selection) => { + this.scripts.set(selection.script, position); + }); + } + + public isEmpty(): boolean { + return this.scripts.size === 0; + } + + public getScriptPositionInCode(script: IScript): ICodePosition { + return this.scripts.get(script); + } +} + +function ensureAllPositionsExist(script: string, positions: ReadonlyArray) { + const totalLines = script.split(/\r\n|\r|\n/).length; + for (const position of positions) { + if (position.endLine > totalLines) { + throw new Error(`script end line (${position.endLine}) is out of range.` + + `(total code lines: ${totalLines}`); + } + } +} + +function getChangedScripts( + oldScripts: ReadonlyArray, + newScripts: ReadonlyArray): ReadonlyArray { + return newScripts + .filter((newScript) => oldScripts.find((oldScript) => oldScript.id === newScript.id + && oldScript.revert !== newScript.revert )) + .map((selection) => selection.script); +} + +function selectIfNotExists( + selectableContainer: ReadonlyArray, + test: ReadonlyArray) { + return selectableContainer + .filter((script) => !test.find((oldScript) => oldScript.id === script.id)) + .map((selection) => selection.script); +} diff --git a/src/application/State/Code/Event/ICodeChangedEvent.ts b/src/application/State/Code/Event/ICodeChangedEvent.ts new file mode 100644 index 00000000..49733de6 --- /dev/null +++ b/src/application/State/Code/Event/ICodeChangedEvent.ts @@ -0,0 +1,11 @@ +import { IScript } from '@/domain/IScript'; +import { ICodePosition } from '@/application/State/Code/Position/ICodePosition'; + +export interface ICodeChangedEvent { + readonly code: string; + addedScripts: ReadonlyArray; + removedScripts: ReadonlyArray; + changedScripts: ReadonlyArray; + isEmpty(): boolean; + getScriptPositionInCode(script: IScript): ICodePosition; +} diff --git a/src/application/State/Code/CodeBuilder.ts b/src/application/State/Code/Generation/CodeBuilder.ts similarity index 83% rename from src/application/State/Code/CodeBuilder.ts rename to src/application/State/Code/Generation/CodeBuilder.ts index aa416843..eb1752be 100644 --- a/src/application/State/Code/CodeBuilder.ts +++ b/src/application/State/Code/Generation/CodeBuilder.ts @@ -4,8 +4,20 @@ const TotalFunctionSeparatorChars = 58; export class CodeBuilder { private readonly lines = new Array(); + // Returns current line starting from 0 (no lines), or 1 (have single line) + public get currentLine(): number { + return this.lines.length; + } + public appendLine(code?: string): CodeBuilder { - this.lines.push(code); + if (!code) { + this.lines.push(''); + return this; + } + const lines = code.match(/[^\r\n]+/g); + for (const line of lines) { + this.lines.push(line); + } return this; } diff --git a/src/application/State/Code/Generation/IUserScript.ts b/src/application/State/Code/Generation/IUserScript.ts new file mode 100644 index 00000000..99d57d31 --- /dev/null +++ b/src/application/State/Code/Generation/IUserScript.ts @@ -0,0 +1,7 @@ +import { SelectedScript } from '@/application/State/Selection/SelectedScript'; +import { ICodePosition } from '@/application/State/Code/Position/ICodePosition'; + +export interface IUserScript { + code: string; + scriptPositions: Map; +} diff --git a/src/application/State/Code/Generation/IUserScriptGenerator.ts b/src/application/State/Code/Generation/IUserScriptGenerator.ts index 1ed4670f..2ac5b05c 100644 --- a/src/application/State/Code/Generation/IUserScriptGenerator.ts +++ b/src/application/State/Code/Generation/IUserScriptGenerator.ts @@ -1,5 +1,7 @@ import { SelectedScript } from '@/application/State/Selection/SelectedScript'; - +import { IUserScript } from './IUserScript'; export interface IUserScriptGenerator { - buildCode(selectedScripts: ReadonlyArray, version: string): string; + buildCode( + selectedScripts: ReadonlyArray, + version: string): IUserScript; } diff --git a/src/application/State/Code/Generation/UserScriptGenerator.ts b/src/application/State/Code/Generation/UserScriptGenerator.ts index 4ec4785a..00e5046c 100644 --- a/src/application/State/Code/Generation/UserScriptGenerator.ts +++ b/src/application/State/Code/Generation/UserScriptGenerator.ts @@ -1,6 +1,9 @@ import { SelectedScript } from '@/application/State/Selection/SelectedScript'; import { IUserScriptGenerator } from './IUserScriptGenerator'; 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', @@ -13,22 +16,51 @@ export const adminRightsScript = { }; export class UserScriptGenerator implements IUserScriptGenerator { - public buildCode(selectedScripts: ReadonlyArray, version: string): string { + public buildCode(selectedScripts: ReadonlyArray, version: string): IUserScript { if (!selectedScripts) { throw new Error('scripts is undefined'); } - if (!selectedScripts.length) { throw new Error('scripts are empty'); } if (!version) { throw new Error('version is undefined'); } - const builder = new CodeBuilder() - .appendLine('@echo off') - .appendCommentLine(`https://privacy.sexy — v${version} — ${new Date().toUTCString()}`) - .appendFunction(adminRightsScript.name, adminRightsScript.code).appendLine(); - for (const selection of selectedScripts) { - const name = selection.revert ? `${selection.script.name} (revert)` : selection.script.name; - const code = selection.revert ? selection.script.revertCode : selection.script.code; - builder.appendFunction(name, code).appendLine(); + let scriptPositions = new Map(); + if (!selectedScripts.length) { + return { code: '', scriptPositions }; } - return builder.appendLine() - .appendLine('pause') - .appendLine('exit /b 0') - .toString(); + const builder = initializeCode(version); + for (const selection of selectedScripts) { + scriptPositions = appendSelection(selection, scriptPositions, builder); + } + const code = finalizeCode(builder); + 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) + .appendLine(); +} + +function finalizeCode(builder: CodeBuilder): string { + return builder.appendLine() + .appendLine('pause') + .appendLine('exit /b 0') + .toString(); +} + +function appendSelection( + selection: SelectedScript, + scriptPositions: Map, + builder: CodeBuilder): Map { + const startPosition = builder.currentLine + 1; + appendCode(selection, builder); + const endPosition = builder.currentLine - 1; + builder.appendLine(); + scriptPositions.set(selection, new CodePosition(startPosition, endPosition)); + return scriptPositions; +} + +function appendCode(selection: SelectedScript, builder: CodeBuilder) { + const name = selection.revert ? `${selection.script.name} (revert)` : selection.script.name; + const scriptCode = selection.revert ? selection.script.revertCode : selection.script.code; + builder.appendFunction(name, scriptCode); +} diff --git a/src/application/State/Code/IApplicationCode.ts b/src/application/State/Code/IApplicationCode.ts index db174f3c..dbafe5ce 100644 --- a/src/application/State/Code/IApplicationCode.ts +++ b/src/application/State/Code/IApplicationCode.ts @@ -1,6 +1,7 @@ +import { ICodeChangedEvent } from './Event/ICodeChangedEvent'; import { ISignal } from '@/infrastructure/Events/ISignal'; export interface IApplicationCode { - readonly changed: ISignal; + readonly changed: ISignal; readonly current: string; } diff --git a/src/application/State/Code/Position/CodePosition.ts b/src/application/State/Code/Position/CodePosition.ts new file mode 100644 index 00000000..ad2a9451 --- /dev/null +++ b/src/application/State/Code/Position/CodePosition.ts @@ -0,0 +1,24 @@ +import { ICodePosition } from './ICodePosition'; +export class CodePosition implements ICodePosition { + + public get totalLines(): number { + return this.endLine - this.startLine; + } + + constructor( + public readonly startLine: number, + public readonly endLine: number) { + if (startLine < 0) { + throw new Error('Code cannot start in a negative line'); + } + if (endLine < 0) { + throw new Error('Code cannot end in a negative line'); + } + if (endLine === startLine) { + throw new Error('Empty code'); + } + if (endLine < startLine) { + throw new Error('End line cannot be less than start line'); + } + } +} diff --git a/src/application/State/Code/Position/ICodePosition.ts b/src/application/State/Code/Position/ICodePosition.ts new file mode 100644 index 00000000..e3e419aa --- /dev/null +++ b/src/application/State/Code/Position/ICodePosition.ts @@ -0,0 +1,5 @@ +export interface ICodePosition { + readonly startLine: number; + readonly endLine: number; + readonly totalLines: number; +} diff --git a/src/application/State/Selection/IUserSelection.ts b/src/application/State/Selection/IUserSelection.ts index 27f971f0..c7bb4a59 100644 --- a/src/application/State/Selection/IUserSelection.ts +++ b/src/application/State/Selection/IUserSelection.ts @@ -6,11 +6,13 @@ export interface IUserSelection { readonly changed: ISignal>; readonly selectedScripts: ReadonlyArray; readonly totalSelected: number; + removeAllInCategory(categoryId: number): void; + addAllInCategory(categoryId: number): void; addSelectedScript(scriptId: string, revert: boolean): void; addOrUpdateSelectedScript(scriptId: string, revert: boolean): void; removeSelectedScript(scriptId: string): void; selectOnly(scripts: ReadonlyArray): void; - isSelected(script: IScript): boolean; + isSelected(scriptId: string): boolean; selectAll(): void; deselectAll(): void; } diff --git a/src/application/State/Selection/UserSelection.ts b/src/application/State/Selection/UserSelection.ts index 54c4d04f..e798ce10 100644 --- a/src/application/State/Selection/UserSelection.ts +++ b/src/application/State/Selection/UserSelection.ts @@ -8,12 +8,13 @@ import { IRepository } from '@/infrastructure/Repository/IRepository'; export class UserSelection implements IUserSelection { public readonly changed = new Signal>(); - private readonly scripts: IRepository = new InMemoryRepository(); + private readonly scripts: IRepository; constructor( private readonly app: IApplication, /** Initially selected scripts */ selectedScripts: ReadonlyArray) { + this.scripts = new InMemoryRepository(); if (selectedScripts && selectedScripts.length > 0) { for (const script of selectedScripts) { const selected = new SelectedScript(script, false); @@ -22,6 +23,33 @@ export class UserSelection implements IUserSelection { } } + public removeAllInCategory(categoryId: number): void { + const category = this.app.findCategory(categoryId); + const scriptsToRemove = category.getAllScriptsRecursively() + .filter((script) => this.scripts.exists(script.id)); + if (!scriptsToRemove.length) { + return; + } + for (const script of scriptsToRemove) { + this.scripts.removeItem(script.id); + } + this.changed.notify(this.scripts.getItems()); + } + + public addAllInCategory(categoryId: number): void { + const category = this.app.findCategory(categoryId); + const scriptsToAdd = category.getAllScriptsRecursively() + .filter((script) => !this.scripts.exists(script.id)); + if (!scriptsToAdd.length) { + return; + } + for (const script of scriptsToAdd) { + const selectedScript = new SelectedScript(script, false); + this.scripts.addItem(selectedScript); + } + this.changed.notify(this.scripts.getItems()); + } + public addSelectedScript(scriptId: string, revert: boolean): void { const script = this.app.findScript(scriptId); if (!script) { @@ -44,8 +72,8 @@ export class UserSelection implements IUserSelection { this.changed.notify(this.scripts.getItems()); } - public isSelected(script: IScript): boolean { - return this.scripts.exists(script.id); + public isSelected(scriptId: string): boolean { + return this.scripts.exists(scriptId); } /** Get users scripts based on his/her selections */ diff --git a/src/domain/Category.ts b/src/domain/Category.ts index d6bc022e..4fa96725 100644 --- a/src/domain/Category.ts +++ b/src/domain/Category.ts @@ -3,15 +3,7 @@ import { IScript } from './IScript'; import { ICategory } from './ICategory'; export class Category extends BaseEntity implements ICategory { - private static validate(category: ICategory) { - if (!category.name) { - throw new Error('name is null or empty'); - } - if ((!category.subCategories || category.subCategories.length === 0) && - (!category.scripts || category.scripts.length === 0)) { - throw new Error('A category must have at least one sub-category or scripts'); - } - } + private allSubScripts: ReadonlyArray = undefined; constructor( id: number, @@ -20,6 +12,27 @@ export class Category extends BaseEntity implements ICategory { public readonly subCategories?: ReadonlyArray, public readonly scripts?: ReadonlyArray) { super(id); - Category.validate(this); + validateCategory(this); + } + + public getAllScriptsRecursively(): readonly IScript[] { + return this.allSubScripts || (this.allSubScripts = parseScriptsRecursively(this)); + } +} + +function parseScriptsRecursively(category: ICategory): ReadonlyArray { + return [ + ...category.scripts, + ...category.subCategories.flatMap((c) => c.getAllScriptsRecursively()), + ]; +} + +function validateCategory(category: ICategory) { + if (!category.name) { + throw new Error('undefined or empty name'); + } + if ((!category.subCategories || category.subCategories.length === 0) && + (!category.scripts || category.scripts.length === 0)) { + throw new Error('A category must have at least one sub-category or script'); } } diff --git a/src/domain/ICategory.ts b/src/domain/ICategory.ts index d67da6a1..e7833fcd 100644 --- a/src/domain/ICategory.ts +++ b/src/domain/ICategory.ts @@ -7,6 +7,7 @@ export interface ICategory extends IEntity, IDocumentable { readonly name: string; readonly subCategories?: ReadonlyArray; readonly scripts?: ReadonlyArray; + getAllScriptsRecursively(): ReadonlyArray; } export { IEntity } from '../infrastructure/Entity/IEntity'; diff --git a/src/presentation/Scripts/ScriptsTree/ScriptNodeParser.ts b/src/presentation/Scripts/ScriptsTree/ScriptNodeParser.ts index 73220850..d05a0b86 100644 --- a/src/presentation/Scripts/ScriptsTree/ScriptNodeParser.ts +++ b/src/presentation/Scripts/ScriptsTree/ScriptNodeParser.ts @@ -1,6 +1,6 @@ import { IApplication } from './../../../domain/IApplication'; import { ICategory, IScript } from '@/domain/ICategory'; -import { INode } from './SelectableTree/INode'; +import { INode, NodeType } from './SelectableTree/Node/INode'; export function parseAllCategories(app: IApplication): INode[] | undefined { const nodes = new Array(); @@ -23,9 +23,15 @@ export function parseSingleCategory(categoryId: number, app: IApplication): INod export function getScriptNodeId(script: IScript): string { return script.id; } +export function getScriptId(nodeId: string): string { + return nodeId; +} +export function getCategoryId(nodeId: string): number { + return +nodeId; +} export function getCategoryNodeId(category: ICategory): string { - return `Category${category.id}`; + return `${category.id}`; } function parseCategoryRecursively( @@ -64,6 +70,7 @@ function convertCategoryToNode( category: ICategory, children: readonly INode[]): INode { return { id: getCategoryNodeId(category), + type: NodeType.Category, text: category.name, children, documentationUrls: category.documentationUrls, @@ -74,6 +81,7 @@ function convertCategoryToNode( function convertScriptToNode(script: IScript): INode { return { id: getScriptNodeId(script), + type: NodeType.Script, text: script.name, children: undefined, documentationUrls: script.documentationUrls, diff --git a/src/presentation/Scripts/ScriptsTree/ScriptsTree.vue b/src/presentation/Scripts/ScriptsTree/ScriptsTree.vue index c9d8bfc8..96fa3664 100644 --- a/src/presentation/Scripts/ScriptsTree/ScriptsTree.vue +++ b/src/presentation/Scripts/ScriptsTree/ScriptsTree.vue @@ -24,10 +24,11 @@ import { ICategory } from '@/domain/ICategory'; import { IApplicationState, IUserSelection } from '@/application/State/IApplicationState'; import { IFilterResult } from '@/application/State/Filter/IFilterResult'; - import { parseAllCategories, parseSingleCategory, getScriptNodeId, getCategoryNodeId } from './ScriptNodeParser'; - import SelectableTree, { FilterPredicate } from './SelectableTree/SelectableTree.vue'; - import { INode } from './SelectableTree/INode'; + import { parseAllCategories, parseSingleCategory, getScriptNodeId, getCategoryNodeId, getCategoryId, getScriptId } from './ScriptNodeParser'; + import SelectableTree from './SelectableTree/SelectableTree.vue'; + import { INode, NodeType } from './SelectableTree/Node/INode'; import { SelectedScript } from '@/application/State/Selection/SelectedScript'; + import { INodeSelectedEvent } from './SelectableTree/INodeSelectedEvent'; @Component({ components: { @@ -53,15 +54,17 @@ await this.initializeNodesAsync(this.categoryId); } - public async toggleNodeSelectionAsync(node: INode) { - if (node.children != null && node.children.length > 0) { - return; // only interested in script nodes - } + public async toggleNodeSelectionAsync(event: INodeSelectedEvent) { const state = await this.getCurrentStateAsync(); - if (!this.selectedNodeIds.some((id) => id === node.id)) { - state.selection.addSelectedScript(node.id, false); - } else { - state.selection.removeSelectedScript(node.id); + switch (event.node.type) { + case NodeType.Category: + this.toggleCategoryNodeSelection(event, state); + break; + case NodeType.Script: + this.toggleScriptNodeSelection(event, state); + break; + default: + throw new Error(`Unknown node type: ${event.node.id}`); } } @@ -97,6 +100,24 @@ this.filterText = result.query; this.filtered = result; } + private toggleCategoryNodeSelection(event: INodeSelectedEvent, state: IApplicationState): void { + const categoryId = getCategoryId(event.node.id); + if (event.isSelected) { + state.selection.addAllInCategory(categoryId); + } else { + state.selection.removeAllInCategory(categoryId); + } + } + private toggleScriptNodeSelection(event: INodeSelectedEvent, state: IApplicationState): void { + const scriptId = getScriptId(event.node.id); + const actualToggleState = state.selection.isSelected(scriptId); + const targetToggleState = event.isSelected; + if (targetToggleState && !actualToggleState) { + state.selection.addSelectedScript(scriptId, false); + } else if (!targetToggleState && actualToggleState) { + state.selection.removeSelectedScript(scriptId); + } + } } diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/INodeSelectedEvent.ts b/src/presentation/Scripts/ScriptsTree/SelectableTree/INodeSelectedEvent.ts new file mode 100644 index 00000000..cd059f6e --- /dev/null +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/INodeSelectedEvent.ts @@ -0,0 +1,6 @@ +import { INode } from './Node/INode'; + +export interface INodeSelectedEvent { + isSelected: boolean; + node: INode; +} diff --git a/src/global.d.ts b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTree.d.ts similarity index 80% rename from src/global.d.ts rename to src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTree.d.ts index 01accdde..b5d7e712 100644 --- a/src/global.d.ts +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTree.d.ts @@ -12,16 +12,25 @@ declare module 'liquor-tree' { setModel(nodes: ReadonlyArray): void; } interface ICustomLiquorTreeData { + type: number; documentationUrls: ReadonlyArray; isReversible: boolean; } + // https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js + export interface ILiquorTreeNodeState { + checked: boolean; + } + export interface ILiquorTreeNode { + id: string; + data: ICustomLiquorTreeData; + children: ReadonlyArray | undefined; + } /** * Returned from Node tree view events. * See constructor in https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js */ - export interface ILiquorTreeExistingNode { - id: string; + export interface ILiquorTreeExistingNode extends ILiquorTreeNode { data: ILiquorTreeNodeData; states: ILiquorTreeNodeState | undefined; children: ReadonlyArray | undefined; @@ -31,12 +40,10 @@ declare module 'liquor-tree' { * Sent to liquor tree to define of new nodes. * https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js */ - export interface ILiquorTreeNewNode { - id: string; + export interface ILiquorTreeNewNode extends ILiquorTreeNode { text: string; state: ILiquorTreeNodeState | undefined; children: ReadonlyArray | undefined; - data: ICustomLiquorTreeData; } // https://amsik.github.io/liquor-tree/#Component-Options @@ -47,13 +54,8 @@ declare module 'liquor-tree' { autoCheckChildren: boolean; parentSelect: boolean; keyboardNavigation: boolean; - deletion: (node: ILiquorTreeExistingNode) => void; filter: ILiquorTreeFilter; - } - - // https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js - interface ILiquorTreeNodeState { - checked: boolean; + deletion(node: ILiquorTreeNode): boolean; } interface ILiquorTreeNodeData extends ICustomLiquorTreeData { @@ -61,15 +63,7 @@ declare module 'liquor-tree' { } // https://github.com/amsik/liquor-tree/blob/master/src/components/TreeRoot.vue - interface ILiquorTreeOptions { - checkbox: boolean; - checkOnSelect: boolean; - filter: ILiquorTreeFilter; - deletion(node: ILiquorTreeNewNode): boolean; - } - - // https://github.com/amsik/liquor-tree/blob/master/src/components/TreeRoot.vue - interface ILiquorTreeFilter { + export interface ILiquorTreeFilter { emptyText: string; matcher(query: string, node: ILiquorTreeExistingNode): boolean; } diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTreeOptions.ts b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTreeOptions.ts new file mode 100644 index 00000000..7dfc636f --- /dev/null +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTreeOptions.ts @@ -0,0 +1,16 @@ +import { ILiquorTreeOptions, ILiquorTreeFilter, ILiquorTreeNode } from 'liquor-tree'; + +export class LiquorTreeOptions implements ILiquorTreeOptions { + public multiple = true; + public checkbox = true; + public checkOnSelect = true; + /* For checkbox mode only. Children will have the same checked state as their parent. + This is false as it's handled manually to be able to batch select for performance + highlighting */ + public autoCheckChildren = false; + public parentSelect = false; + public keyboardNavigation = true; + constructor(public filter: ILiquorTreeFilter) { } + public deletion(node: ILiquorTreeNode): boolean { + return false; // no op + } +} diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodePredicateFilter.ts b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodePredicateFilter.ts new file mode 100644 index 00000000..d012449f --- /dev/null +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodePredicateFilter.ts @@ -0,0 +1,17 @@ +import { ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree'; +import { convertExistingToNode } from './NodeTranslator'; +import { INode } from '../Node/INode'; + +export type FilterPredicate = (node: INode) => boolean; + +export class NodePredicateFilter implements ILiquorTreeFilter { + public emptyText: string = '🕵️Hmm.. Can not see one 🧐'; + constructor(private readonly filterPredicate: FilterPredicate) { + if (!filterPredicate) { + throw new Error('filterPredicate is undefined'); + } + } + public matcher(query: string, node: ILiquorTreeExistingNode): boolean { + return this.filterPredicate(convertExistingToNode(node)); + } +} diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeStateUpdater.ts b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeStateUpdater.ts new file mode 100644 index 00000000..360b41b6 --- /dev/null +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeStateUpdater.ts @@ -0,0 +1,66 @@ +import { ILiquorTreeExistingNode, ILiquorTreeNewNode, ILiquorTreeNodeState, ILiquorTreeNode } from 'liquor-tree'; +import { NodeType } from './../Node/INode'; + +export function updateNodesCheckedState( + oldNodes: ReadonlyArray, + selectedNodeIds: ReadonlyArray): ReadonlyArray { + const result = new Array(); + for (const oldNode of oldNodes) { + const newState = oldNode.states; + newState.checked = getNewCheckedState(oldNode, selectedNodeIds); + const newNode: ILiquorTreeNewNode = { + id: oldNode.id, + text: oldNode.data.text, + data: { + type: oldNode.data.type, + documentationUrls: oldNode.data.documentationUrls, + isReversible: oldNode.data.isReversible, + }, + children: !oldNode.children ? [] : updateNodesCheckedState(oldNode.children, selectedNodeIds), + state: newState, + }; + result.push(newNode); + } + return result; +} + +export function getNewCheckedState( + oldNode: ILiquorTreeNode, + selectedNodeIds: ReadonlyArray): boolean { + switch (oldNode.data.type) { + case NodeType.Script: + return selectedNodeIds.some((id) => id === oldNode.id); + case NodeType.Category: + return parseAllSubScriptIds(oldNode).every((id) => selectedNodeIds.includes(id)); + default: + throw new Error('Unknown node type'); + } +} + +function parseAllSubScriptIds(categoryNode: ILiquorTreeNode): ReadonlyArray { + if (categoryNode.data.type !== NodeType.Category) { + throw new Error('Not a category node'); + } + if (!categoryNode.children) { + return []; + } + const ids = new Array(); + for (const child of categoryNode.children) { + addNodeIds(child, ids); + } + return ids; +} + +function addNodeIds(node: ILiquorTreeNode, ids: string[]) { + switch (node.data.type) { + case NodeType.Script: + ids.push(node.id); + break; + case NodeType.Category: + const subCategoryIds = parseAllSubScriptIds(node); + ids.push(...subCategoryIds); + break; + default: + throw new Error('Unknown node type'); + } +} diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/NodeTranslator.ts b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeTranslator.ts similarity index 92% rename from src/presentation/Scripts/ScriptsTree/SelectableTree/NodeTranslator.ts rename to src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeTranslator.ts index ad449c69..a583a545 100644 --- a/src/presentation/Scripts/ScriptsTree/SelectableTree/NodeTranslator.ts +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/LiquorTree/NodeTranslator.ts @@ -1,5 +1,5 @@ import { ILiquorTreeNewNode, ILiquorTreeExistingNode } from 'liquor-tree'; -import { INode } from './INode'; +import { INode } from './../Node/INode'; // Functions to translate INode to LiqourTree models and vice versa for anti-corruption @@ -7,6 +7,7 @@ export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode): if (!liquorTreeNode) { throw new Error('liquorTreeNode is undefined'); } return { id: liquorTreeNode.id, + type: liquorTreeNode.data.type, text: liquorTreeNode.data.text, // selected: liquorTreeNode.states && liquorTreeNode.states.checked, children: convertChildren(liquorTreeNode.children, convertExistingToNode), @@ -27,6 +28,7 @@ export function toNewLiquorTreeNode(node: INode): ILiquorTreeNewNode { data: { documentationUrls: node.documentationUrls, isReversible: node.isReversible, + type: node.type, }, }; } diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/DocumentationUrls.vue b/src/presentation/Scripts/ScriptsTree/SelectableTree/Node/DocumentationUrls.vue similarity index 100% rename from src/presentation/Scripts/ScriptsTree/SelectableTree/DocumentationUrls.vue rename to src/presentation/Scripts/ScriptsTree/SelectableTree/Node/DocumentationUrls.vue diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/INode.ts b/src/presentation/Scripts/ScriptsTree/SelectableTree/Node/INode.ts similarity index 72% rename from src/presentation/Scripts/ScriptsTree/SelectableTree/INode.ts rename to src/presentation/Scripts/ScriptsTree/SelectableTree/Node/INode.ts index 5210774e..ddd58391 100644 --- a/src/presentation/Scripts/ScriptsTree/SelectableTree/INode.ts +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/Node/INode.ts @@ -1,7 +1,13 @@ +export enum NodeType { + Script, + Category, +} + export interface INode { readonly id: string; readonly text: string; readonly isReversible: boolean; readonly documentationUrls: ReadonlyArray; readonly children?: ReadonlyArray; + readonly type: NodeType; } diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/Node.vue b/src/presentation/Scripts/ScriptsTree/SelectableTree/Node/Node.vue similarity index 100% rename from src/presentation/Scripts/ScriptsTree/SelectableTree/Node.vue rename to src/presentation/Scripts/ScriptsTree/SelectableTree/Node/Node.vue diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/RevertToggle.vue b/src/presentation/Scripts/ScriptsTree/SelectableTree/Node/RevertToggle.vue similarity index 100% rename from src/presentation/Scripts/ScriptsTree/SelectableTree/RevertToggle.vue rename to src/presentation/Scripts/ScriptsTree/SelectableTree/Node/RevertToggle.vue diff --git a/src/presentation/Scripts/ScriptsTree/SelectableTree/SelectableTree.vue b/src/presentation/Scripts/ScriptsTree/SelectableTree/SelectableTree.vue index 2d110552..19b827b0 100644 --- a/src/presentation/Scripts/ScriptsTree/SelectableTree/SelectableTree.vue +++ b/src/presentation/Scripts/ScriptsTree/SelectableTree/SelectableTree.vue @@ -19,16 +19,19 @@ diff --git a/src/presentation/TheCodeArea.vue b/src/presentation/TheCodeArea.vue index 249dda4c..1435be73 100644 --- a/src/presentation/TheCodeArea.vue +++ b/src/presentation/TheCodeArea.vue @@ -7,7 +7,9 @@ import { Component, Prop, Watch, Vue } from 'vue-property-decorator'; import { StatefulVue } from './StatefulVue'; import ace from 'ace-builds'; import 'ace-builds/webpack-resolver'; -import { CodeBuilder } from '../application/State/Code/CodeBuilder'; +import { CodeBuilder } from '@/application/State/Code/Generation/CodeBuilder'; +import { ICodeChangedEvent } from '@/application/State/Code/Event/ICodeChangedEvent'; +import { IScript } from '@/domain/IScript'; const NothingChosenCode = new CodeBuilder() @@ -28,19 +30,65 @@ const NothingChosenCode = @Component export default class TheCodeArea extends StatefulVue { public readonly editorId = 'codeEditor'; + private editor!: ace.Ace.Editor; + private currentMarkerId?: number; @Prop() private theme!: string; public async mounted() { this.editor = initializeEditor(this.theme, this.editorId); const state = await this.getCurrentStateAsync(); - this.updateCode(state.code.current); + this.editor.setValue(state.code.current || NothingChosenCode, 1); state.code.changed.on((code) => this.updateCode(code)); } - private updateCode(code: string) { - this.editor.setValue(code || NothingChosenCode, 1); + private updateCode(event: ICodeChangedEvent) { + this.removeCurrentHighlighting(); + if (event.isEmpty()) { + this.editor.setValue(NothingChosenCode, 1); + return; + } + this.editor.setValue(event.code, 1); + + if (event.addedScripts && event.addedScripts.length) { + this.reactToChanges(event, event.addedScripts); + } else if (event.changedScripts && event.changedScripts.length) { + this.reactToChanges(event, event.changedScripts); + } + } + + private reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray) { + const positions = scripts + .map((script) => event.getScriptPositionInCode(script)); + const start = Math.min( + ...positions.map((position) => position.startLine), + ); + const end = Math.max( + ...positions.map((position) => position.endLine), + ); + this.scrollToLine(end + 2); + this.highlight(start, end); + } + + private highlight(startRow: number, endRow: number) { + const AceRange = ace.require('ace/range').Range; + this.currentMarkerId = this.editor.session.addMarker( + new AceRange(startRow, 0, endRow, 0), 'code-area__highlight', 'fullLine', + ); + } + + private scrollToLine(row: number) { + const column = this.editor.session.getLine(row).length; + this.editor.gotoLine(row, column, true); + } + + private removeCurrentHighlighting() { + if (!this.currentMarkerId) { + return; + } + this.editor.session.removeMarker(this.currentMarkerId); + this.currentMarkerId = undefined; } } @@ -58,12 +106,16 @@ function initializeEditor(theme: string, editorId: string): ace.Ace.Editor { - diff --git a/src/presentation/TheCodeButtons.vue b/src/presentation/TheCodeButtons.vue index 7a79e26f..c6eff969 100644 --- a/src/presentation/TheCodeButtons.vue +++ b/src/presentation/TheCodeButtons.vue @@ -33,7 +33,7 @@ export default class TheCodeButtons extends StatefulVue { const state = await this.getCurrentStateAsync(); this.hasCode = state.code.current && state.code.current.length > 0; state.code.changed.on((code) => { - this.hasCode = code && code.length > 0; + this.hasCode = code && code.code.length > 0; }); } diff --git a/tests/unit/application/State/Code/ApplicationCode.spec.ts b/tests/unit/application/State/Code/ApplicationCode.spec.ts index 3b9ecab1..aab7703d 100644 --- a/tests/unit/application/State/Code/ApplicationCode.spec.ts +++ b/tests/unit/application/State/Code/ApplicationCode.spec.ts @@ -1,11 +1,15 @@ +import 'mocha'; +import { expect } from 'chai'; import { CategoryStub } from './../../../stubs/CategoryStub'; import { ScriptStub } from './../../../stubs/ScriptStub'; import { ApplicationStub } from './../../../stubs/ApplicationStub'; import { UserSelection } from '@/application/State/Selection/UserSelection'; import { ApplicationCode } from '@/application/State/Code/ApplicationCode'; -import 'mocha'; -import { expect } from 'chai'; import { SelectedScript } from '@/application/State/Selection/SelectedScript'; +import { ICodeChangedEvent } from '@/application/State/Code/Event/ICodeChangedEvent'; +import { IUserScriptGenerator } from '@/application/State/Code/Generation/IUserScriptGenerator'; +import { CodePosition } from '@/application/State/Code/Position/CodePosition'; +import { ICodePosition } from '@/application/State/Code/Position/ICodePosition'; describe('ApplicationCode', () => { describe('ctor', () => { @@ -31,35 +35,76 @@ describe('ApplicationCode', () => { expect(actual).to.have.length.greaterThan(0).and.include(version); }); }); - describe('user selection changes', () => { - it('empty when selection is empty', () => { - // arrange - let signaled: string; - const scripts = [new ScriptStub('first'), new ScriptStub('second')]; - const app = new ApplicationStub().withAction(new CategoryStub(1).withScripts(...scripts)); - const selection = new UserSelection(app, scripts); - const sut = new ApplicationCode(selection, 'version'); - sut.changed.on((code) => signaled = code); - // act - selection.changed.notify([]); - // assert - expect(signaled).to.have.lengthOf(0); - expect(signaled).to.equal(sut.current); + describe('changed event', () => { + describe('code', () => { + it('empty when nothing is selected', () => { + // 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); + const sut = new ApplicationCode(selection, 'version'); + sut.changed.on((code) => signaled = code); + // act + selection.changed.notify([]); + // assert + expect(signaled.code).to.have.lengthOf(0); + expect(signaled.code).to.equal(sut.current); + }); + it('has code when some are selected', () => { + // 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); + const version = 'version-string'; + const sut = new ApplicationCode(selection, version); + 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.equal(sut.current); + }); }); - it('has code when selection is not empty', () => { + it('sets positions from the generator', () => { // arrange - let signaled: string; + 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); - const version = 'version-string'; - const sut = new ApplicationCode(selection, version); + const expectedVersion = 'version-string'; + const scriptsToSelect = scripts.map((s) => new SelectedScript(s, false)); + const totalLines = 20; + const expected = new Map( + [ + [ scriptsToSelect[0], new CodePosition(0, totalLines / 2)], + [ scriptsToSelect[1], new CodePosition(totalLines / 2, totalLines)], + ], + ); + 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'); + } + return { + code: '\nREM LINE'.repeat(totalLines), + scriptPositions: expected, + }; + }, + }; + const sut = new ApplicationCode(selection, expectedVersion, generatorMock); sut.changed.on((code) => signaled = code); // act - selection.changed.notify(scripts.map((s) => new SelectedScript(s, false))); + selection.changed.notify(scriptsToSelect); // assert - expect(signaled).to.have.length.greaterThan(0).and.include(version); - expect(signaled).to.equal(sut.current); + expect(signaled.getScriptPositionInCode(scripts[0])) + .to.deep.equal(expected.get(scriptsToSelect[0])); + expect(signaled.getScriptPositionInCode(scripts[1])) + .to.deep.equal(expected.get(scriptsToSelect[1])); }); }); }); diff --git a/tests/unit/application/State/Code/Event/CodeChangedEvent.spec.ts b/tests/unit/application/State/Code/Event/CodeChangedEvent.spec.ts new file mode 100644 index 00000000..03b16740 --- /dev/null +++ b/tests/unit/application/State/Code/Event/CodeChangedEvent.spec.ts @@ -0,0 +1,147 @@ +import 'mocha'; +import { expect } from 'chai'; +import { CodeChangedEvent } from '@/application/State/Code/Event/CodeChangedEvent'; +import { SelectedScript } from '@/application/State/Selection/SelectedScript'; +import { ICodePosition } from '@/application/State/Code/Position/ICodePosition'; +import { CodePosition } from '@/application/State/Code/Position/CodePosition'; +import { SelectedScriptStub } from '../../../../stubs/SelectedScriptStub'; +import { ScriptStub } from '../../../../stubs/ScriptStub'; + +describe('CodeChangedEvent', () => { + describe('ctor', () => { + describe('position validation', () => { + it('throws when code position is out of range', () => { + const act = () => new CodeChangedEvent( + 'singleline code', [], new Map([ + [ new SelectedScriptStub('1'), new CodePosition(0, 2) ], + ]), + ); + expect(act).to.throw(); + }); + it('does not throw with valid code position', () => { + const act = () => new CodeChangedEvent( + 'singleline code', [], new Map([ + [ new SelectedScriptStub('1'), new CodePosition(0, 1) ], + ]), + ); + expect(act).to.not.throw(); + }); + }); + }); + it('code returns expected', () => { + // arrange + const expected = 'code'; + // act + const sut = new CodeChangedEvent( + expected, [], new Map(), + ); + const actual = sut.code; + // assert + expect(actual).to.equal(expected); + }); + describe('addedScripts', () => { + it('returns new scripts when scripts are added', () => { + // arrange + const expected = [ new ScriptStub('3'), new ScriptStub('4') ]; + const initialScripts = [ new SelectedScriptStub('1'), new SelectedScriptStub('2') ]; + const newScripts = new Map([ + [initialScripts[0], new CodePosition(0, 1) ], + [initialScripts[1], new CodePosition(0, 1) ], + [new SelectedScript(expected[0], false), new CodePosition(0, 1) ], + [new SelectedScript(expected[1], false), new CodePosition(0, 1) ], + ]); + // act + const sut = new CodeChangedEvent( + 'code', initialScripts, newScripts, + ); + const actual = sut.addedScripts; + // assert + expect(actual).to.have.lengthOf(2); + expect(actual[0]).to.deep.equal(expected[0]); + expect(actual[1]).to.deep.equal(expected[1]); + }); + }); + describe('removedScripts', () => { + it('returns removed scripts when script are removed', () => { + // arrange + const existingScripts = [ new SelectedScriptStub('0'), new SelectedScriptStub('1') ]; + const removedScripts = [ new SelectedScriptStub('2') ]; + const initialScripts = [ ...existingScripts, ...removedScripts ]; + const newScripts = new Map([ + [initialScripts[0], new CodePosition(0, 1) ], + [initialScripts[1], new CodePosition(0, 1) ], + ]); + // act + const sut = new CodeChangedEvent( + 'code', initialScripts, newScripts, + ); + const actual = sut.removedScripts; + // assert + expect(actual).to.have.lengthOf(removedScripts.length); + expect(actual[0]).to.deep.equal(removedScripts[0].script); + }); + }); + describe('changedScripts', () => { + it('returns changed scripts when scripts are changed', () => { + // arrange + const initialScripts = [ new SelectedScriptStub('1', false), new SelectedScriptStub('2', false) ]; + const newScripts = new Map([ + [new SelectedScriptStub('1', true), new CodePosition(0, 1) ], + [new SelectedScriptStub('2', false), new CodePosition(0, 1) ], + ]); + // act + const sut = new CodeChangedEvent( + 'code', initialScripts, newScripts, + ); + const actual = sut.changedScripts; + // assert + expect(actual).to.have.lengthOf(1); + expect(actual[0]).to.deep.equal(initialScripts[0].script); + }); + }); + describe('isEmpty', () => { + it('returns true when empty', () => { + // arrange + const newScripts = new Map(); + const oldScripts = [ new SelectedScriptStub('1', false) ]; + const sut = new CodeChangedEvent( + 'code', oldScripts, newScripts, + ); + // act + const actual = sut.isEmpty(); + // assert + expect(actual).to.equal(true); + }); + it('returns false when not empty', () => { + // arrange + const oldScripts = [ new SelectedScriptStub('1') ]; + const newScripts = new Map( [ + [oldScripts[0], new CodePosition(0, 1) ], + ]); + const sut = new CodeChangedEvent( + 'code', oldScripts, newScripts, + ); + // act + const actual = sut.isEmpty(); + // assert + expect(actual).to.equal(false); + }); + }); + describe('getScriptPositionInCode', () => { + it('returns expected position for existing script', () => { + // arrange + const script = new ScriptStub('1'); + const expected = new CodePosition(0, 1); + const newScripts = new Map( [ + [new SelectedScript(script, false), expected ], + ]); + const sut = new CodeChangedEvent( + 'code', [], newScripts, + ); + // act + const actual = sut.getScriptPositionInCode(script); + // assert + expect(actual).to.deep.equal(expected); + }); + }); +}); diff --git a/tests/unit/application/State/Code/Generation/CodeBuilder.spec.ts b/tests/unit/application/State/Code/Generation/CodeBuilder.spec.ts new file mode 100644 index 00000000..b50d5b48 --- /dev/null +++ b/tests/unit/application/State/Code/Generation/CodeBuilder.spec.ts @@ -0,0 +1,112 @@ +import 'mocha'; +import { expect } from 'chai'; +import { CodeBuilder } from '@/application/State/Code/Generation/CodeBuilder'; + +describe('CodeBuilder', () => { + describe('appendLine', () => { + it('when empty appends empty line', () => { + // arrange + const sut = new CodeBuilder(); + // act + sut.appendLine().appendLine().appendLine(); + // assert + expect(sut.toString()).to.equal('\n\n'); + }); + it('when not empty append string in new line', () => { + // arrange + const sut = new CodeBuilder(); + const expected = 'str'; + // act + sut.appendLine() + .appendLine(expected); + // assert + const result = sut.toString(); + const lines = getLines(result); + expect(lines[1]).to.equal('str'); + }); + }); + it('appendFunction', () => { + // arrange + const sut = new CodeBuilder(); + const functionName = 'function'; + const code = 'code'; + // act + sut.appendFunction(functionName, code); + // assert + const result = sut.toString(); + expect(result).to.include(functionName); + expect(result).to.include(code); + }); + it('appendTrailingHyphensCommentLine', () => { + // arrange + const sut = new CodeBuilder(); + const totalHypens = 5; + const expected = `:: ${'-'.repeat(totalHypens)}`; + // act + sut.appendTrailingHyphensCommentLine(totalHypens); + // assert + const result = sut.toString(); + const lines = getLines(result); + expect(lines[0]).to.equal(expected); + }); + it('appendCommentLine', () => { + // arrange + const sut = new CodeBuilder(); + const comment = 'comment'; + const expected = ':: comment'; + // act + sut.appendCommentLine(comment); + // assert + const result = sut.toString(); + const lines = getLines(result); + expect(lines[0]).to.equal(expected); + }); + it('appendCommentLineWithHyphensAround', () => { + // arrange + const sut = new CodeBuilder(); + const sectionName = 'section'; + const totalHypens = sectionName.length + 3 * 2; + const expected = ':: ---section---'; + sut.appendCommentLineWithHyphensAround(sectionName, totalHypens); + // assert + const result = sut.toString(); + const lines = getLines(result); + expect(lines[1]).to.equal(expected); + }); + describe('currentLine', () => { + it('no lines returns zero', () => { + // arrange & act + const sut = new CodeBuilder(); + // assert + expect(sut.currentLine).to.equal(0); + }); + it('single line returns one', () => { + // arrange + const sut = new CodeBuilder(); + // act + sut.appendLine(); + // assert + expect(sut.currentLine).to.equal(1); + }); + it('multiple lines returns as expected', () => { + // arrange + const sut = new CodeBuilder(); + // act + sut.appendLine('1').appendCommentLine('2').appendLine(); + // assert + expect(sut.currentLine).to.equal(3); + }); + it('multiple lines in code', () => { + // arrange + const sut = new CodeBuilder(); + // act + sut.appendLine('hello\ncode-here\nwith-3-lines'); + // assert + expect(sut.currentLine).to.equal(3); + }); + }); +}); + +function getLines(text: string): string[] { + return text.split(/\r\n|\r|\n/); +} diff --git a/tests/unit/application/State/Code/Generation/UserScriptGenerator.spec.ts b/tests/unit/application/State/Code/Generation/UserScriptGenerator.spec.ts index da267c32..65f486e1 100644 --- a/tests/unit/application/State/Code/Generation/UserScriptGenerator.spec.ts +++ b/tests/unit/application/State/Code/Generation/UserScriptGenerator.spec.ts @@ -1,33 +1,34 @@ -import { ScriptStub } from './../../../stubs/ScriptStub'; -import { UserScriptGenerator, adminRightsScript } from '@/application/State/Code/UserScriptGenerator'; +import { ScriptStub } from '../../../../stubs/ScriptStub'; +import { UserScriptGenerator, adminRightsScript } from '@/application/State/Code/Generation/UserScriptGenerator'; import 'mocha'; import { expect } from 'chai'; import { SelectedScript } from '@/application/State/Selection/SelectedScript'; +import { SelectedScriptStub } from '../../../../stubs/SelectedScriptStub'; describe('UserScriptGenerator', () => { it('adds version', () => { - const sut = new UserScriptGenerator(); // arrange + const sut = new UserScriptGenerator(); const version = '1.5.0'; const selectedScripts = [ new SelectedScript(new ScriptStub('id'), false)]; // act const actual = sut.buildCode(selectedScripts, version); // assert - expect(actual).to.include(version); + expect(actual.code).to.include(version); }); it('adds admin rights function', () => { - const sut = new UserScriptGenerator(); // arrange + const sut = new UserScriptGenerator(); const selectedScripts = [ new SelectedScript(new ScriptStub('id'), false)]; // act const actual = sut.buildCode(selectedScripts, 'non-important-version'); // assert - expect(actual).to.include(adminRightsScript.code); - expect(actual).to.include(adminRightsScript.name); + expect(actual.code).to.include(adminRightsScript.code); + expect(actual.code).to.include(adminRightsScript.name); }); it('appends revert script', () => { - const sut = new UserScriptGenerator(); // arrange + const sut = new UserScriptGenerator(); const scriptName = 'test non-revert script'; const scriptCode = 'REM nop'; const script = new ScriptStub('id').withName(scriptName).withRevertCode(scriptCode); @@ -35,8 +36,8 @@ describe('UserScriptGenerator', () => { // act const actual = sut.buildCode(selectedScripts, 'non-important-version'); // assert - expect(actual).to.include(`${scriptName} (revert)`); - expect(actual).to.include(scriptCode); + expect(actual.code).to.include(`${scriptName} (revert)`); + expect(actual.code).to.include(scriptCode); }); it('appends non-revert script', () => { const sut = new UserScriptGenerator(); @@ -48,7 +49,46 @@ describe('UserScriptGenerator', () => { // act const actual = sut.buildCode(selectedScripts, 'non-important-version'); // assert - expect(actual).to.include(scriptName); - expect(actual).to.include(scriptCode); + expect(actual.code).to.include(scriptName); + expect(actual.code).to.include(scriptCode); + }); + describe('scriptPositions', () => { + it('single script', () => { + // 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)]; + // act + const actual = sut.buildCode(selectedScripts, 'non-important-version'); + // assert + expect(actual.scriptPositions.size).to.equal(1); + const position = actual.scriptPositions.get(selectedScripts[0]); + expect(position.endLine).to.be.greaterThan(position.startLine + 2); + }); + it('multiple scripts', () => { + // arrange + const sut = new UserScriptGenerator(); + const selectedScripts = [ new SelectedScriptStub('1'), new SelectedScriptStub('2') ]; + // act + const actual = sut.buildCode(selectedScripts, 'non-important-version'); + // 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); + }); }); }); diff --git a/tests/unit/application/State/Code/Position/CodePosition.spec.ts b/tests/unit/application/State/Code/Position/CodePosition.spec.ts new file mode 100644 index 00000000..8e7df886 --- /dev/null +++ b/tests/unit/application/State/Code/Position/CodePosition.spec.ts @@ -0,0 +1,54 @@ +import { CodePosition } from '@/application/State/Code/Position/CodePosition'; +import 'mocha'; +import { expect } from 'chai'; + +describe('CodePosition', () => { + describe('ctor', () => { + it('creates with valid parameters', () => { + // arrange + const startPosition = 0; + const endPosition = 5; + // act + const sut = new CodePosition(startPosition, endPosition); + // assert + expect(sut.startLine).to.equal(startPosition); + expect(sut.endLine).to.equal(endPosition); + }); + it('throws with negative start position', () => { + // arrange + const startPosition = -1; + const endPosition = 5; + // act + const getSut = () => new CodePosition(startPosition, endPosition); + // assert + expect(getSut).to.throw('Code cannot start in a negative line'); + }); + it('throws with negative end position', () => { + // arrange + const startPosition = 1; + const endPosition = -5; + // act + const getSut = () => new CodePosition(startPosition, endPosition); + // assert + expect(getSut).to.throw('Code cannot end in a negative line'); + }); + it('throws when start and end position is same', () => { + // arrange + const startPosition = 0; + const endPosition = 0; + // act + const getSut = () => new CodePosition(startPosition, endPosition); + // assert + expect(getSut).to.throw('Empty code'); + }); + it('throws when ends before start', () => { + // arrange + const startPosition = 3; + const endPosition = 2; + // act + const getSut = () => new CodePosition(startPosition, endPosition); + // assert + expect(getSut).to.throw('End line cannot be less than start line'); + }); + }); +}); diff --git a/tests/unit/application/State/Selection/UserSelection.spec.ts b/tests/unit/application/State/Selection/UserSelection.spec.ts index 14159364..4ce41e86 100644 --- a/tests/unit/application/State/Selection/UserSelection.spec.ts +++ b/tests/unit/application/State/Selection/UserSelection.spec.ts @@ -93,4 +93,127 @@ describe('UserSelection', () => { expect(events[0]).to.deep.equal(expected); }); }); + describe('removeAllInCategory', () => { + it('does nothing when nothing exists', () => { + // arrange + const events: Array = []; + const categoryId = 1; + const app = new ApplicationStub() + .withAction(new CategoryStub(categoryId) + .withScripts(new ScriptStub('s1'), new ScriptStub('s2'))); + const sut = new UserSelection(app, []); + sut.changed.on((s) => events.push(s)); + // act + sut.removeAllInCategory(categoryId); + // assert + expect(events).to.have.lengthOf(0); + expect(sut.selectedScripts).to.have.lengthOf(0); + }); + it('removes all when all exists', () => { + // arrange + const categoryId = 1; + const scripts = [new ScriptStub('s1'), new ScriptStub('s2')]; + const app = new ApplicationStub() + .withAction(new CategoryStub(categoryId) + .withScripts(...scripts)); + const sut = new UserSelection(app, scripts); + // act + sut.removeAllInCategory(categoryId); + // assert + expect(sut.totalSelected).to.equal(0); + expect(sut.selectedScripts.length).to.equal(0); + }); + it('removes existing some exists', () => { + // arrange + const categoryId = 1; + const existing = [new ScriptStub('s1'), new ScriptStub('s2')]; + const notExisting = [new ScriptStub('s3'), new ScriptStub('s4')]; + const app = new ApplicationStub() + .withAction(new CategoryStub(categoryId) + .withScripts(...existing, ...notExisting)); + const sut = new UserSelection(app, existing); + // act + sut.removeAllInCategory(categoryId); + // assert + expect(sut.totalSelected).to.equal(0); + expect(sut.selectedScripts.length).to.equal(0); + }); + }); + describe('addAllInCategory', () => { + it('does nothing when all already exists', () => { + // arrange + const events: Array = []; + const categoryId = 1; + const scripts = [new ScriptStub('s1'), new ScriptStub('s2')]; + const app = new ApplicationStub() + .withAction(new CategoryStub(categoryId) + .withScripts(...scripts)); + const sut = new UserSelection(app, scripts); + sut.changed.on((s) => events.push(s)); + // act + sut.addAllInCategory(categoryId); + // assert + expect(events).to.have.lengthOf(0); + expect(sut.selectedScripts.map((script) => script.id)) + .to.have.deep.members(scripts.map((script) => script.id)); + }); + it('adds all when nothing exists', () => { + // arrange + const categoryId = 1; + const expected = [new ScriptStub('s1'), new ScriptStub('s2')]; + const app = new ApplicationStub() + .withAction(new CategoryStub(categoryId) + .withScripts(...expected)); + const sut = new UserSelection(app, []); + // act + sut.addAllInCategory(categoryId); + // assert + expect(sut.selectedScripts.map((script) => script.id)) + .to.have.deep.members(expected.map((script) => script.id)); + }); + it('adds not existing some exists', () => { + // arrange + const categoryId = 1; + const notExisting = [ new ScriptStub('notExisting1'), new ScriptStub('notExisting2') ]; + const existing = [ new ScriptStub('existing1'), new ScriptStub('existing2') ]; + const allScripts = [ ...existing, ...notExisting ]; + const app = new ApplicationStub() + .withAction(new CategoryStub(categoryId) + .withScripts(...allScripts)); + const sut = new UserSelection(app, existing); + // act + sut.addAllInCategory(categoryId); + // assert + expect(sut.selectedScripts.map((script) => script.id)) + .to.have.deep.members(allScripts.map((script) => script.id)); + }); + }); + describe('isSelected', () => { + it('returns false when not selected', () => { + // arrange + const selectedScript = new ScriptStub('selected'); + const notSelectedScript = new ScriptStub('not selected'); + const app = new ApplicationStub() + .withAction(new CategoryStub(1) + .withScripts(selectedScript, notSelectedScript)); + const sut = new UserSelection(app, [ selectedScript ]); + // act + const actual = sut.isSelected(notSelectedScript.id); + // assert + expect(actual).to.equal(false); + }); + it('returns true when selected', () => { + // arrange + const selectedScript = new ScriptStub('selected'); + const notSelectedScript = new ScriptStub('not selected'); + const app = new ApplicationStub() + .withAction(new CategoryStub(1) + .withScripts(selectedScript, notSelectedScript)); + const sut = new UserSelection(app, [ selectedScript ]); + // act + const actual = sut.isSelected(selectedScript.id); + // assert + expect(actual).to.equal(true); + }); + }); }); diff --git a/tests/unit/domain/Category.spec.ts b/tests/unit/domain/Category.spec.ts new file mode 100644 index 00000000..5d726a12 --- /dev/null +++ b/tests/unit/domain/Category.spec.ts @@ -0,0 +1,89 @@ +import 'mocha'; +import { expect } from 'chai'; +import { Category } from '@/domain/Category'; +import { CategoryStub } from '../stubs/CategoryStub'; +import { ScriptStub } from '../stubs/ScriptStub'; + +describe('Category', () => { + describe('ctor', () => { + it('throws when name is empty', () => { + const expectedError = 'undefined or empty name'; + const construct = () => new Category(5, '', [], [new CategoryStub(5)], []); + expect(construct).to.throw(expectedError); + }); + it('throws when has no children', () => { + const expectedError = 'A category must have at least one sub-category or script'; + const construct = () => new Category(5, 'category', [], [], []); + expect(construct).to.throw(expectedError); + }); + }); + describe('getAllScriptsRecursively', () => { + it('gets child scripts', () => { + // arrange + const expected = [ new ScriptStub('1'), new ScriptStub('2') ]; + const sut = new Category(0, 'category', [], [], expected); + // act + const actual = sut.getAllScriptsRecursively(); + // assert + expect(actual).to.have.deep.members(expected); + }); + it('gets child categories', () => { + // arrange + const expectedScriptIds = ['1', '2', '3', '4']; + const categories = [ + new CategoryStub(31).withScriptIds('1', '2'), + new CategoryStub(32).withScriptIds('3', '4'), + ]; + const sut = new Category(0, 'category', [], categories, []); + // act + const actualIds = sut.getAllScriptsRecursively().map((s) => s.id); + // assert + expect(actualIds).to.have.deep.members(expectedScriptIds); + + }); + it('gets child scripts and categories', () => { + // arrange + const expectedScriptIds = ['1', '2', '3', '4', '5' , '6']; + const categories = [ + new CategoryStub(31).withScriptIds('1', '2'), + new CategoryStub(32).withScriptIds('3', '4'), + ]; + const scripts = [ new ScriptStub('5'), new ScriptStub('6') ]; + const sut = new Category(0, 'category', [], categories, scripts); + // act + const actualIds = sut.getAllScriptsRecursively().map((s) => s.id); + // assert + expect(actualIds).to.have.deep.members(expectedScriptIds); + + }); + it('gets child categories recursively', () => { + // arrange + const expectedScriptIds = ['1', '2', '3', '4', '5', '6']; + const categories = [ + new CategoryStub(31) + .withScriptIds('1', '2') + .withCategory( + new CategoryStub(32) + .withScriptIds('3', '4'), + ), + new CategoryStub(33) + .withCategories( + new CategoryStub(34) + .withScriptIds('5') + .withCategory( + new CategoryStub(35) + .withCategory( + new CategoryStub(35).withScriptIds('6'), + ), + ), + ), + ]; + // assert + const sut = new Category(0, 'category', [], categories, []); + // act + const actualIds = sut.getAllScriptsRecursively().map((s) => s.id); + // assert + expect(actualIds).to.have.deep.members(expectedScriptIds); + }); + }); +}); diff --git a/tests/unit/stubs/ApplicationStub.ts b/tests/unit/stubs/ApplicationStub.ts index 69a55e36..adc507ef 100644 --- a/tests/unit/stubs/ApplicationStub.ts +++ b/tests/unit/stubs/ApplicationStub.ts @@ -13,10 +13,11 @@ export class ApplicationStub implements IApplication { return this; } public findCategory(categoryId: number): ICategory { - throw new Error('Method not implemented.'); + return this.getAllCategories().find( + (category) => category.id === categoryId); } public getRecommendedScripts(): readonly IScript[] { - throw new Error('Method not implemented.'); + throw new Error('Method not implemented: getRecommendedScripts'); } public findScript(scriptId: string): IScript { return this.getAllScripts().find((script) => scriptId === script.id); diff --git a/tests/unit/stubs/CategoryStub.ts b/tests/unit/stubs/CategoryStub.ts index a1b32751..2d966763 100644 --- a/tests/unit/stubs/CategoryStub.ts +++ b/tests/unit/stubs/CategoryStub.ts @@ -11,6 +11,14 @@ export class CategoryStub extends BaseEntity implements ICategory { constructor(id: number) { super(id); } + + public getAllScriptsRecursively(): readonly IScript[] { + return [ + ...this.scripts, + ...this.subCategories.flatMap((c) => c.getAllScriptsRecursively()), + ]; + } + public withScriptIds(...scriptIds: string[]): CategoryStub { for (const scriptId of scriptIds) { this.withScript(new ScriptStub(scriptId)); diff --git a/tests/unit/stubs/SelectedScriptStub.ts b/tests/unit/stubs/SelectedScriptStub.ts new file mode 100644 index 00000000..1c352d9c --- /dev/null +++ b/tests/unit/stubs/SelectedScriptStub.ts @@ -0,0 +1,8 @@ +import { SelectedScript } from '@/application/State/Selection/SelectedScript'; +import { ScriptStub } from './ScriptStub'; + +export class SelectedScriptStub extends SelectedScript { + constructor(id: string, revert = false) { + super(new ScriptStub(id), revert); + } +}