diff --git a/docs/presentation.md b/docs/presentation.md index d2648b2e..aa45debc 100644 --- a/docs/presentation.md +++ b/docs/presentation.md @@ -91,7 +91,14 @@ Shared components include: Desktop builds uses `electron-vite` to bundle the code, and `electron-builder` to build and publish the packages. -## Sass naming convention +## Styles + +### Style location + +- **Global styles**: The [`assets/styles/`](#structure) directory is reserved for styles that have a broader scope, affecting multiple components or entire layouts. They are generic and should not be tightly coupled to a specific component's functionality. +- **Component-specific styles**: Styles closely tied to a particular component's functionality or appearance should reside near the component they are used by. This makes it easier to locate and modify styles when working on a specific component. + +### Sass naming convention - Use lowercase for variables/functions/mixins, e.g.: - Variable: `$variable: value;` diff --git a/package-lock.json b/package-lock.json index a18d8fea..a05562ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "cross-fetch": "^4.0.0", "electron-progressbar": "^2.1.0", "file-saver": "^2.0.5", - "liquor-tree": "^0.2.70", "markdown-it": "^13.0.1", "npm": "^9.8.1", "v-tooltip": "2.1.3", @@ -10762,11 +10761,6 @@ "uc.micro": "^1.0.1" } }, - "node_modules/liquor-tree": { - "version": "0.2.70", - "resolved": "https://registry.npmjs.org/liquor-tree/-/liquor-tree-0.2.70.tgz", - "integrity": "sha512-5CiMlDVmuveYwwc27mYe1xZ3J4aHhZBErUhIp9ov4v4wIBso+s5JAByOOit4iOCMCQ5ODd8VggbKymzZREYbBQ==" - }, "node_modules/listr2": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", @@ -29242,11 +29236,6 @@ "uc.micro": "^1.0.1" } }, - "liquor-tree": { - "version": "0.2.70", - "resolved": "https://registry.npmjs.org/liquor-tree/-/liquor-tree-0.2.70.tgz", - "integrity": "sha512-5CiMlDVmuveYwwc27mYe1xZ3J4aHhZBErUhIp9ov4v4wIBso+s5JAByOOit4iOCMCQ5ODd8VggbKymzZREYbBQ==" - }, "listr2": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", diff --git a/package.json b/package.json index 2e55b21d..a9f38ea4 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "cross-fetch": "^4.0.0", "electron-progressbar": "^2.1.0", "file-saver": "^2.0.5", - "liquor-tree": "^0.2.70", "markdown-it": "^13.0.1", "npm": "^9.8.1", "v-tooltip": "2.1.3", diff --git a/src/presentation/assets/styles/_mixins.scss b/src/presentation/assets/styles/_mixins.scss index c6d2d3fd..1c0bb3da 100644 --- a/src/presentation/assets/styles/_mixins.scss +++ b/src/presentation/assets/styles/_mixins.scss @@ -44,4 +44,10 @@ transform: translateY($offset-upward); } } -} \ No newline at end of file +} + +@mixin reset-ul { + margin: 0; + padding: 0; + list-style: none; +} diff --git a/src/presentation/assets/styles/main.scss b/src/presentation/assets/styles/main.scss index e869bc4c..a951f445 100644 --- a/src/presentation/assets/styles/main.scss +++ b/src/presentation/assets/styles/main.scss @@ -9,4 +9,3 @@ @forward "./components/card"; @forward "./third-party-extensions/tooltip.scss"; -@forward "./third-party-extensions/tree.scss"; diff --git a/src/presentation/assets/styles/third-party-extensions/_tree.scss b/src/presentation/assets/styles/third-party-extensions/_tree.scss deleted file mode 100644 index 7e8ce51d..00000000 --- a/src/presentation/assets/styles/third-party-extensions/_tree.scss +++ /dev/null @@ -1,62 +0,0 @@ -// Overrides base styling for LiquorTree -@use "@/presentation/assets/styles/colors" as *; -@use "@/presentation/assets/styles/mixins" as *; - -$color-tree-bg : $color-primary-darker; -$color-node-arrow : $color-on-primary; -$color-node-fg : $color-on-primary; -$color-node-hover-bg : $color-primary-dark; -$color-node-keyboard-bg : $color-surface; -$color-node-keyboard-fg : $color-on-surface; -$color-node-checkbox-bg-checked : $color-secondary; -$color-node-checkbox-bg-unchecked : $color-primary-darkest; -$color-node-checkbox-border-checked : $color-secondary; -$color-node-checkbox-border-unchecked : $color-on-primary; -$color-node-checkbox-tick-checked : $color-on-secondary; - -.tree { - background: $color-tree-bg; - &-node { - white-space: normal !important; - > .tree-content { - > .tree-anchor { - > span { - color: $color-node-fg; - font-size: 1.5em; - } - display: block; // so it takes full width to allow aligning items inside - } - @include hover-or-touch { - background: $color-node-hover-bg !important; - } - background: $color-tree-bg !important; // If not styled, it gets white background on mobile. - } - &.selected { // When using keyboard navigation it highlights current item and its child items - background: $color-node-keyboard-bg; - .tree-text { - color: $color-node-keyboard-fg !important; // $block - } - } - } - &-checkbox { - border-color: $color-node-checkbox-border-unchecked !important; - &.checked { - background: $color-node-checkbox-bg-checked !important; - border-color: $color-node-checkbox-border-checked !important; - &:after { - border-color: $color-node-checkbox-tick-checked !important; - } - } - &.indeterminate { - border-color: $color-node-checkbox-border-unchecked !important; - } - background: $color-node-checkbox-bg-unchecked !important; - } - &-arrow { - &.has-child { - &.rtl:after, &:after { - border-color: $color-node-arrow !important; - } - } - } -} diff --git a/src/presentation/bootstrapping/ApplicationBootstrapper.ts b/src/presentation/bootstrapping/ApplicationBootstrapper.ts index d73ca3cd..44d048dc 100644 --- a/src/presentation/bootstrapping/ApplicationBootstrapper.ts +++ b/src/presentation/bootstrapping/ApplicationBootstrapper.ts @@ -1,4 +1,3 @@ -import { TreeBootstrapper } from './Modules/TreeBootstrapper'; import { IconBootstrapper } from './Modules/IconBootstrapper'; import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper'; import { VueBootstrapper } from './Modules/VueBootstrapper'; @@ -17,7 +16,6 @@ export class ApplicationBootstrapper implements IVueBootstrapper { private static getAllBootstrappers(): IVueBootstrapper[] { return [ new IconBootstrapper(), - new TreeBootstrapper(), new VueBootstrapper(), new TooltipBootstrapper(), new RuntimeSanityValidator(), diff --git a/src/presentation/bootstrapping/Modules/TreeBootstrapper.ts b/src/presentation/bootstrapping/Modules/TreeBootstrapper.ts deleted file mode 100644 index 705ca763..00000000 --- a/src/presentation/bootstrapping/Modules/TreeBootstrapper.ts +++ /dev/null @@ -1,8 +0,0 @@ -import LiquorTree from 'liquor-tree'; -import { VueConstructor, IVueBootstrapper } from '../IVueBootstrapper'; - -export class TreeBootstrapper implements IVueBootstrapper { - public bootstrap(vue: VueConstructor): void { - vue.use(LiquorTree); - } -} diff --git a/src/presentation/components/Code/TheCodeArea.vue b/src/presentation/components/Code/TheCodeArea.vue index f50dbb18..fd19dfd6 100644 --- a/src/presentation/components/Code/TheCodeArea.vue +++ b/src/presentation/components/Code/TheCodeArea.vue @@ -76,7 +76,6 @@ export default defineComponent({ function handleCodeChange(event: ICodeChangedEvent) { removeCurrentHighlighting(); updateCode(event.code, currentState.value.collection.scripting.language); - editor.setValue(event.code, 1); if (event.addedScripts?.length > 0) { reactToChanges(event, event.addedScripts); } else if (event.changedScripts?.length > 0) { diff --git a/src/presentation/components/Scripts/View/Cards/CardListItem.vue b/src/presentation/components/Scripts/View/Cards/CardListItem.vue index 394c4ee0..b106b735 100644 --- a/src/presentation/components/Scripts/View/Cards/CardListItem.vue +++ b/src/presentation/components/Scripts/View/Cards/CardListItem.vue @@ -53,7 +53,7 @@ import { inject, } from 'vue'; import { InjectionKeys } from '@/presentation/injectionSymbols'; -import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue'; +import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue'; import { sleep } from '@/infrastructure/Threading/AsyncSleep'; export default defineComponent({ diff --git a/src/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue b/src/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue deleted file mode 100644 index cb69b78e..00000000 --- a/src/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue +++ /dev/null @@ -1,173 +0,0 @@ - - - diff --git a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/INodeSelectedEvent.ts b/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/INodeSelectedEvent.ts deleted file mode 100644 index dd340c99..00000000 --- a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/INodeSelectedEvent.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { INodeContent } from './Node/INodeContent'; - -export interface INodeSelectedEvent { - isSelected: boolean; - node: INodeContent; -} diff --git a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/LiquorTree.d.ts b/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/LiquorTree.d.ts deleted file mode 100644 index 5c49fce9..00000000 --- a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/LiquorTree.d.ts +++ /dev/null @@ -1,76 +0,0 @@ -declare module 'liquor-tree' { - import { PluginObject } from 'vue'; - - // https://github.com/amsik/liquor-tree/blob/master/src/lib/Tree.js - export interface ILiquorTree { - readonly model: ReadonlyArray; - filter(query: string): void; - clearFilter(): void; - setModel(nodes: ReadonlyArray): void; - // getNodeById(id: string): ILiquorTreeExistingNode; - recurseDown(fn: (node: ILiquorTreeExistingNode) => void): void; - } - export interface ICustomLiquorTreeData { - type: number; - docs: ReadonlyArray; - isReversible: boolean; - } - // https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js - export interface ILiquorTreeNodeState { - checked: boolean; - indeterminate: 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 extends ILiquorTreeNode { - data: ILiquorTreeNodeData; - states: ILiquorTreeNodeState | undefined; - children: ReadonlyArray | undefined; - // expand(): void; - } - - /** - Sent to liquor tree to define of new nodes. - https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js - */ - export interface ILiquorTreeNewNode extends ILiquorTreeNode { - text: string; - state: ILiquorTreeNodeState | undefined; - children: ReadonlyArray | undefined; - } - - // https://amsik.github.io/liquor-tree/#Component-Options - export interface ILiquorTreeOptions { - multiple: boolean; - checkbox: boolean; - checkOnSelect: boolean; - autoCheckChildren: boolean; - parentSelect: boolean; - keyboardNavigation: boolean; - filter: ILiquorTreeFilter; - deletion(node: ILiquorTreeNode): boolean; - } - - export interface ILiquorTreeNodeData extends ICustomLiquorTreeData { - text: string; - } - - // https://github.com/amsik/liquor-tree/blob/master/src/components/TreeRoot.vue - export interface ILiquorTreeFilter { - emptyText: string; - matcher(query: string, node: ILiquorTreeExistingNode): boolean; - } - - interface LiquorTreeVueComponent extends PluginObject { - install(Vue: VueConstructor, options?: unknown); - } - export default LiquorTree; -} diff --git a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/LiquorTreeOptions.ts b/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/LiquorTreeOptions.ts deleted file mode 100644 index 5c382f4f..00000000 --- a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/LiquorTreeOptions.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ILiquorTreeOptions, ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree'; - -export class LiquorTreeOptions implements ILiquorTreeOptions { - public readonly multiple = true; - - public readonly checkbox = true; - - public readonly checkOnSelect = true; - - /* - For checkbox mode only. Children will have the same checked state as their parent. - ⚠️ Setting this false does not prevent updating indeterminate state of nodes. - It's set to false anyway because state is handled manually, and this way batch selections can - be done in more performant way. - */ - public readonly autoCheckChildren = false; - - public readonly parentSelect = true; - - public readonly keyboardNavigation = true; - - /* - Filter is wrapped in an arrow function because setting filter directly does not work with - underling JavaScript APIs. - */ - public readonly filter = { - emptyText: this.liquorTreeFilter.emptyText, - matcher: (query: string, node: ILiquorTreeExistingNode) => { - return this.liquorTreeFilter.matcher(query, node); - }, - }; - - constructor(private readonly liquorTreeFilter: ILiquorTreeFilter) { } - - public deletion(): boolean { - return false; // no op - } -} diff --git a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter.ts b/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter.ts deleted file mode 100644 index ce3f814b..00000000 --- a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree'; -import { INodeContent } from '../../Node/INodeContent'; -import { convertExistingToNode } from './NodeTranslator'; - -export type FilterPredicate = (node: INodeContent) => boolean; - -export class NodePredicateFilter implements ILiquorTreeFilter { - public emptyText = ''; // Does not matter as a custom message is shown - - 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/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater.ts b/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater.ts deleted file mode 100644 index 84d8a488..00000000 --- a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ILiquorTreeNode, ILiquorTreeNodeState } from 'liquor-tree'; -import { NodeType } from '../../Node/INodeContent'; - -export function getNewState( - node: ILiquorTreeNode, - selectedNodeIds: ReadonlyArray, -): ILiquorTreeNodeState { - const checked = getNewCheckedState(node, selectedNodeIds); - const indeterminate = !checked && getNewIndeterminateState(node, selectedNodeIds); - return { - checked, indeterminate, - }; -} - -function getNewIndeterminateState( - node: ILiquorTreeNode, - selectedNodeIds: ReadonlyArray, -): boolean { - switch (node.data.type) { - case NodeType.Script: - return false; - case NodeType.Category: - return parseAllSubScriptIds(node).some((id) => selectedNodeIds.includes(id)); - default: - throw new Error('Unknown node type'); - } -} - -function getNewCheckedState( - node: ILiquorTreeNode, - selectedNodeIds: ReadonlyArray, -): boolean { - switch (node.data.type) { - case NodeType.Script: - return selectedNodeIds.some((id) => id === node.id); - case NodeType.Category: - return parseAllSubScriptIds(node).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 []; - } - return categoryNode - .children - .flatMap((child) => getNodeIds(child)); -} - -function getNodeIds(node: ILiquorTreeNode): ReadonlyArray { - switch (node.data.type) { - case NodeType.Script: - return [node.id]; - case NodeType.Category: - return parseAllSubScriptIds(node); - default: - throw new Error('Unknown node type'); - } -} diff --git a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator.ts b/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator.ts deleted file mode 100644 index ac9bd4d9..00000000 --- a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ILiquorTreeNewNode, ILiquorTreeExistingNode } from 'liquor-tree'; -import { INodeContent } from '../../Node/INodeContent'; - -// Functions to translate INode to LiqourTree models and vice versa for anti-corruption - -export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode): INodeContent { - 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), - docs: liquorTreeNode.data.docs, - isReversible: liquorTreeNode.data.isReversible, - }; -} - -export function toNewLiquorTreeNode(node: INodeContent): ILiquorTreeNewNode { - if (!node) { throw new Error('node is undefined'); } - return { - id: node.id, - text: node.text, - state: { - checked: false, - indeterminate: false, - }, - children: convertChildren(node.children, toNewLiquorTreeNode), - data: { - docs: node.docs, - isReversible: node.isReversible, - type: node.type, - }, - }; -} - -function convertChildren( - oldChildren: readonly TOldNode[], - callback: (value: TOldNode) => TNewNode, -): TNewNode[] { - if (!oldChildren || oldChildren.length === 0) { - return []; - } - return oldChildren.map((childNode) => callback(childNode)); -} diff --git a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/SelectableTree.vue b/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/SelectableTree.vue deleted file mode 100644 index 36c9e7ad..00000000 --- a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/SelectableTree.vue +++ /dev/null @@ -1,196 +0,0 @@ - - - diff --git a/src/presentation/components/Scripts/View/TheScriptsView.vue b/src/presentation/components/Scripts/View/TheScriptsView.vue index 80703688..b815ba15 100644 --- a/src/presentation/components/Scripts/View/TheScriptsView.vue +++ b/src/presentation/components/Scripts/View/TheScriptsView.vue @@ -42,7 +42,7 @@ import { inject, } from 'vue'; import { InjectionKeys } from '@/presentation/injectionSymbols'; -import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue'; +import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue'; import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue'; import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType'; import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter'; @@ -139,7 +139,8 @@ $margin-inner: 4px; padding-top: 15px; padding-bottom: 15px; &--searching { - padding-top: 0px; + background-color: $color-primary-darker; + padding-top: 0px; } } } diff --git a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/DocumentableNode.vue b/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentableNode.vue similarity index 100% rename from src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/DocumentableNode.vue rename to src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentableNode.vue diff --git a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/DocumentationText.vue b/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentationText.vue similarity index 100% rename from src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/DocumentationText.vue rename to src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/DocumentationText.vue diff --git a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/MarkdownRenderer.ts b/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer.ts similarity index 100% rename from src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/MarkdownRenderer.ts rename to src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer.ts diff --git a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/ToggleDocumentationButton.vue b/src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/ToggleDocumentationButton.vue similarity index 100% rename from src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/ToggleDocumentationButton.vue rename to src/presentation/components/Scripts/View/Tree/NodeContent/Documentation/ToggleDocumentationButton.vue diff --git a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/NodeContent.vue b/src/presentation/components/Scripts/View/Tree/NodeContent/NodeContent.vue similarity index 71% rename from src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/NodeContent.vue rename to src/presentation/components/Scripts/View/Tree/NodeContent/NodeContent.vue index 336311af..a1fe1b5b 100644 --- a/src/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/NodeContent.vue +++ b/src/presentation/components/Scripts/View/Tree/NodeContent/NodeContent.vue @@ -1,18 +1,18 @@ diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputFilterEvent.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputFilterEvent.ts new file mode 100644 index 00000000..664df496 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputFilterEvent.ts @@ -0,0 +1,38 @@ +import type { ReadOnlyTreeNode } from '../Node/TreeNode'; + +export interface TreeViewFilterEvent { + readonly action: TreeViewFilterAction; + /** + * A simple numeric value to ensure uniqueness of each event. + * + * This property is used to guarantee that the watch function will trigger + * even if the same filter action value is emitted consecutively. + */ + readonly timestamp: Date; + + readonly predicate?: TreeViewFilterPredicate; +} + +export enum TreeViewFilterAction { + Triggered, + Removed, +} + +export type TreeViewFilterPredicate = (node: ReadOnlyTreeNode) => boolean; + +export function createFilterTriggeredEvent( + predicate: TreeViewFilterPredicate, +): TreeViewFilterEvent { + return { + action: TreeViewFilterAction.Triggered, + timestamp: new Date(), + predicate, + }; +} + +export function createFilterRemovedEvent(): TreeViewFilterEvent { + return { + action: TreeViewFilterAction.Removed, + timestamp: new Date(), + }; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData.ts new file mode 100644 index 00000000..7964bfd6 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData.ts @@ -0,0 +1,6 @@ +export interface TreeInputNodeData { + readonly id: string; + readonly children?: readonly TreeInputNodeData[]; + readonly parent?: TreeInputNodeData | null; + readonly data?: object; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeNodeStateChangedEmittedEvent.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeNodeStateChangedEmittedEvent.ts new file mode 100644 index 00000000..270f8b38 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeNodeStateChangedEmittedEvent.ts @@ -0,0 +1,7 @@ +import { NodeStateChangedEvent } from '../Node/State/StateAccess'; +import { ReadOnlyTreeNode } from '../Node/TreeNode'; + +export interface TreeNodeStateChangedEmittedEvent { + readonly change: NodeStateChangedEvent; + readonly node: ReadOnlyTreeNode; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Node/HierarchicalTreeNode.vue b/src/presentation/components/Scripts/View/Tree/TreeView/Node/HierarchicalTreeNode.vue new file mode 100644 index 00000000..e6b6b2d9 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Node/HierarchicalTreeNode.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/HierarchyAccess.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/HierarchyAccess.ts new file mode 100644 index 00000000..b935795a --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/HierarchyAccess.ts @@ -0,0 +1,19 @@ +import type { ReadOnlyTreeNode, TreeNode } from '../TreeNode'; + +export interface HierarchyReader { + readonly depthInTree: number; + readonly parent: ReadOnlyTreeNode | undefined; + readonly children: readonly ReadOnlyTreeNode[]; + readonly isLeafNode: boolean; + readonly isBranchNode: boolean; +} + +export interface HierarchyWriter { + setParent(parent: TreeNode): void; + setChildren(children: readonly TreeNode[]): void; +} + +export interface HierarchyAccess extends HierarchyReader, HierarchyWriter { + readonly parent: TreeNode | undefined; + readonly children: readonly TreeNode[]; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/TreeNodeHierarchy.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/TreeNodeHierarchy.ts new file mode 100644 index 00000000..8a47bad7 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/TreeNodeHierarchy.ts @@ -0,0 +1,31 @@ +import { TreeNode } from '../TreeNode'; +import { HierarchyAccess } from './HierarchyAccess'; + +export class TreeNodeHierarchy implements HierarchyAccess { + public parent: TreeNode | undefined = undefined; + + public get depthInTree(): number { + if (!this.parent) { + return 0; + } + return this.parent.hierarchy.depthInTree + 1; + } + + public get isLeafNode(): boolean { + return this.children.length === 0; + } + + public get isBranchNode(): boolean { + return this.children.length > 0; + } + + public children: readonly TreeNode[]; + + public setChildren(children: readonly TreeNode[]): void { + this.children = children; + } + + public setParent(parent: TreeNode): void { + this.parent = parent; + } +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Node/LeafTreeNode.vue b/src/presentation/components/Scripts/View/Tree/TreeView/Node/LeafTreeNode.vue new file mode 100644 index 00000000..e94e101b --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Node/LeafTreeNode.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState.ts new file mode 100644 index 00000000..048437b4 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState.ts @@ -0,0 +1,5 @@ +export enum TreeNodeCheckState { + Unchecked = 0, + Checked = 1, + Indeterminate = 2, +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess.ts new file mode 100644 index 00000000..b5c0d186 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess.ts @@ -0,0 +1,43 @@ +import { IEventSource } from '@/infrastructure/Events/IEventSource'; +import { TreeNodeStateDescriptor } from './StateDescriptor'; +import { TreeNodeCheckState } from './CheckState'; + +export interface NodeStateChangedEvent { + readonly oldState: TreeNodeStateDescriptor; + readonly newState: TreeNodeStateDescriptor; +} + +export interface TreeNodeStateReader { + readonly current: TreeNodeStateDescriptor; + readonly changed: IEventSource; +} + +/* + The transactional approach allows for batched state changes. + Instead of firing a state change event for every single operation, + multiple changes can be batched into a single transaction. + This ensures that listeners to the state change event are + only notified once per batch of changes, optimizing performance + and reducing potential event handling overhead. +*/ +export interface TreeNodeStateTransactor { + beginTransaction(): TreeNodeStateTransaction; + commitTransaction(transaction: TreeNodeStateTransaction): void; +} + +export interface TreeNodeStateTransaction { + withExpansionState(isExpanded: boolean): TreeNodeStateTransaction; + withMatchState(isMatched: boolean): TreeNodeStateTransaction; + withFocusState(isFocused: boolean): TreeNodeStateTransaction; + withVisibilityState(isVisible: boolean): TreeNodeStateTransaction; + withCheckState(checkState: TreeNodeCheckState): TreeNodeStateTransaction; + readonly updatedState: Partial; +} + +export interface TreeNodeStateWriter extends TreeNodeStateTransactor { + toggleCheck(): void; + toggleExpand(): void; +} + +export interface TreeNodeStateAccess + extends TreeNodeStateReader, TreeNodeStateWriter { } diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor.ts new file mode 100644 index 00000000..59fb4c5e --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor.ts @@ -0,0 +1,9 @@ +import { TreeNodeCheckState } from './CheckState'; + +export interface TreeNodeStateDescriptor { + readonly checkState: TreeNodeCheckState; + readonly isExpanded: boolean; + readonly isVisible: boolean; + readonly isMatched: boolean; + readonly isFocused: boolean; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeState.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeState.ts new file mode 100644 index 00000000..a46cebef --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeState.ts @@ -0,0 +1,66 @@ +import { EventSource } from '@/infrastructure/Events/EventSource'; +import { NodeStateChangedEvent, TreeNodeStateAccess, TreeNodeStateTransaction } from './StateAccess'; +import { TreeNodeStateDescriptor } from './StateDescriptor'; +import { TreeNodeCheckState } from './CheckState'; +import { TreeNodeStateTransactionDescriber } from './TreeNodeStateTransactionDescriber'; + +export class TreeNodeState implements TreeNodeStateAccess { + public current: TreeNodeStateDescriptor = { + checkState: TreeNodeCheckState.Unchecked, + isExpanded: false, + isVisible: true, + isMatched: false, + isFocused: false, + }; + + public readonly changed = new EventSource(); + + public beginTransaction(): TreeNodeStateTransaction { + return new TreeNodeStateTransactionDescriber(); + } + + public commitTransaction(transaction: TreeNodeStateTransaction): void { + const oldState = this.current; + const newState: TreeNodeStateDescriptor = { + ...this.current, + ...transaction.updatedState, + }; + if (areEqual(oldState, newState)) { + return; + } + this.current = newState; + const event: NodeStateChangedEvent = { + oldState, + newState, + }; + this.changed.notify(event); + } + + public toggleCheck(): void { + const checkStateTransitions: { + readonly [K in TreeNodeCheckState]: TreeNodeCheckState; + } = { + [TreeNodeCheckState.Checked]: TreeNodeCheckState.Unchecked, + [TreeNodeCheckState.Unchecked]: TreeNodeCheckState.Checked, + [TreeNodeCheckState.Indeterminate]: TreeNodeCheckState.Unchecked, + }; + + this.commitTransaction( + this.beginTransaction().withCheckState(checkStateTransitions[this.current.checkState]), + ); + } + + public toggleExpand(): void { + this.commitTransaction( + this.beginTransaction().withExpansionState(!this.current.isExpanded), + ); + } +} + +function areEqual(first: TreeNodeStateDescriptor, second: TreeNodeStateDescriptor): boolean { + return first.isFocused === second.isFocused + && first.isMatched === second.isMatched + && first.isVisible === second.isVisible + && first.isExpanded === second.isExpanded + && first.checkState === second.checkState; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeStateTransactionDescriber.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeStateTransactionDescriber.ts new file mode 100644 index 00000000..71fd5452 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeStateTransactionDescriber.ts @@ -0,0 +1,44 @@ +import { TreeNodeCheckState } from './CheckState'; +import { TreeNodeStateTransaction } from './StateAccess'; +import { TreeNodeStateDescriptor } from './StateDescriptor'; + +export class TreeNodeStateTransactionDescriber implements TreeNodeStateTransaction { + constructor(public updatedState: Partial = {}) { } + + public withExpansionState(isExpanded: boolean): TreeNodeStateTransaction { + return this.describeChange({ + isExpanded, + }); + } + + public withMatchState(isMatched: boolean): TreeNodeStateTransaction { + return this.describeChange({ + isMatched, + }); + } + + public withFocusState(isFocused: boolean): TreeNodeStateTransaction { + return this.describeChange({ + isFocused, + }); + } + + public withVisibilityState(isVisible: boolean): TreeNodeStateTransaction { + return this.describeChange({ + isVisible, + }); + } + + public withCheckState(checkState: TreeNodeCheckState): TreeNodeStateTransaction { + return this.describeChange({ + checkState, + }); + } + + private describeChange(changedState: Partial): TreeNodeStateTransaction { + return new TreeNodeStateTransactionDescriber({ + ...this.updatedState, + ...changedState, + }); + } +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode.ts new file mode 100644 index 00000000..3442bc39 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode.ts @@ -0,0 +1,14 @@ +import type { HierarchyAccess, HierarchyReader } from './Hierarchy/HierarchyAccess'; +import type { TreeNodeStateAccess, TreeNodeStateReader } from './State/StateAccess'; + +export interface ReadOnlyTreeNode { + readonly id: string; + readonly state: TreeNodeStateReader; + readonly hierarchy: HierarchyReader; + readonly metadata?: object; +} + +export interface TreeNode extends ReadOnlyTreeNode { + readonly state: TreeNodeStateAccess; + readonly hierarchy: HierarchyAccess; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNodeManager.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNodeManager.ts new file mode 100644 index 00000000..bf044b3d --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNodeManager.ts @@ -0,0 +1,21 @@ +import { TreeNode } from './TreeNode'; +import { TreeNodeStateAccess } from './State/StateAccess'; +import { TreeNodeState } from './State/TreeNodeState'; +import { HierarchyAccess } from './Hierarchy/HierarchyAccess'; +import { TreeNodeHierarchy } from './Hierarchy/TreeNodeHierarchy'; + +export class TreeNodeManager implements TreeNode { + public readonly state: TreeNodeStateAccess; + + public readonly hierarchy: HierarchyAccess; + + constructor(public readonly id: string, public readonly metadata?: object) { + if (!id) { + throw new Error('missing id'); + } + + this.hierarchy = new TreeNodeHierarchy(); + + this.state = new TreeNodeState(); + } +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState.ts new file mode 100644 index 00000000..18bb9166 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState.ts @@ -0,0 +1,36 @@ +import { ref, onMounted, onUnmounted } from 'vue'; + +export function useKeyboardInteractionState(window: WindowWithEventListeners = globalThis.window) { + const isKeyboardBeingUsed = ref(false); + + const enableKeyboardFocus = () => { + if (isKeyboardBeingUsed.value) { + return; + } + isKeyboardBeingUsed.value = true; + }; + + const disableKeyboardFocus = () => { + if (!isKeyboardBeingUsed.value) { + return; + } + isKeyboardBeingUsed.value = false; + }; + + onMounted(() => { + window.addEventListener('keydown', enableKeyboardFocus, true); + window.addEventListener('click', disableKeyboardFocus, true); + }); + + onUnmounted(() => { + window.removeEventListener('keydown', enableKeyboardFocus); + window.removeEventListener('click', disableKeyboardFocus); + }); + + return { isKeyboardBeingUsed }; +} + +export interface WindowWithEventListeners { + addEventListener: typeof global.window.addEventListener; + removeEventListener: typeof global.window.removeEventListener; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Node/UseNodeState.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Node/UseNodeState.ts new file mode 100644 index 00000000..09976197 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Node/UseNodeState.ts @@ -0,0 +1,30 @@ +import { + WatchSource, inject, ref, watch, +} from 'vue'; +import { InjectionKeys } from '@/presentation/injectionSymbols'; +import { ReadOnlyTreeNode } from './TreeNode'; +import { TreeNodeStateDescriptor } from './State/StateDescriptor'; + +export function useNodeState( + nodeWatcher: WatchSource, +) { + const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)(); + + const state = ref(); + + watch(nodeWatcher, (node: ReadOnlyTreeNode) => { + if (!node) { + return; + } + state.value = node.state.current; + events.unsubscribeAllAndRegister([ + node.state.changed.on((change) => { + state.value = change.newState; + }), + ]); + }, { immediate: true }); + + return { + state, + }; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/NodeRenderingStrategy.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/NodeRenderingStrategy.ts new file mode 100644 index 00000000..b7801986 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/NodeRenderingStrategy.ts @@ -0,0 +1,5 @@ +import { TreeNode } from '../Node/TreeNode'; + +export interface NodeRenderingStrategy { + shouldRender(node: TreeNode): boolean; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering.ts new file mode 100644 index 00000000..bd03a36a --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering.ts @@ -0,0 +1,117 @@ +import { + WatchSource, computed, shallowRef, triggerRef, watch, +} from 'vue'; +import { ReadOnlyTreeNode } from '../Node/TreeNode'; +import { useNodeStateChangeAggregator } from '../UseNodeStateChangeAggregator'; +import { TreeRoot } from '../TreeRoot/TreeRoot'; +import { useCurrentTreeNodes } from '../UseCurrentTreeNodes'; +import { NodeRenderingStrategy } from './NodeRenderingStrategy'; + +/** + * Renders tree nodes gradually to prevent UI freeze when loading large amounts of nodes. + */ +export function useGradualNodeRendering( + treeWatcher: WatchSource, +): NodeRenderingStrategy { + const nodesToRender = new Set(); + const nodesBeingRendered = shallowRef(new Set()); + let isFirstRender = true; + let isRenderingInProgress = false; + const renderingDelayInMs = 50; + const initialBatchSize = 30; + const subsequentBatchSize = 5; + + const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher); + const { nodes } = useCurrentTreeNodes(treeWatcher); + + const orderedNodes = computed(() => nodes.value.flattenedNodes); + + watch(() => orderedNodes.value, (newNodes) => { + newNodes.forEach((node) => updateNodeRenderQueue(node)); + }, { immediate: true }); + + function updateNodeRenderQueue(node: ReadOnlyTreeNode) { + if (node.state.current.isVisible + && !nodesToRender.has(node) + && !nodesBeingRendered.value.has(node)) { + nodesToRender.add(node); + if (!isRenderingInProgress) { + scheduleRendering(); + } + } else if (!node.state.current.isVisible) { + if (nodesToRender.has(node)) { + nodesToRender.delete(node); + } + if (nodesBeingRendered.value.has(node)) { + nodesBeingRendered.value.delete(node); + triggerRef(nodesBeingRendered); + } + } + } + + onNodeStateChange((node, change) => { + if (change.newState.isVisible === change.oldState.isVisible) { + return; + } + updateNodeRenderQueue(node); + }); + + scheduleRendering(); + + function scheduleRendering() { + if (isFirstRender) { + renderNodeBatch(); + isFirstRender = false; + } else { + const delayScheduler = new DelayScheduler(renderingDelayInMs); + delayScheduler.schedule(renderNodeBatch); + } + } + + function renderNodeBatch() { + if (nodesToRender.size === 0) { + isRenderingInProgress = false; + return; + } + isRenderingInProgress = true; + const batchSize = isFirstRender ? initialBatchSize : subsequentBatchSize; + const sortedNodes = Array.from(nodesToRender).sort( + (a, b) => orderedNodes.value.indexOf(a) - orderedNodes.value.indexOf(b), + ); + const currentBatch = sortedNodes.slice(0, batchSize); + currentBatch.forEach((node) => { + nodesToRender.delete(node); + nodesBeingRendered.value.add(node); + }); + triggerRef(nodesBeingRendered); + if (nodesToRender.size > 0) { + scheduleRendering(); + } + } + + function shouldNodeBeRendered(node: ReadOnlyTreeNode) { + return nodesBeingRendered.value.has(node); + } + + return { + shouldRender: shouldNodeBeRendered, + }; +} + +class DelayScheduler { + private timeoutId: ReturnType = null; + + constructor(private delay: number) {} + + schedule(callback: () => void) { + this.clear(); + this.timeoutId = setTimeout(callback, this.delay); + } + + clear() { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + } +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager.ts b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager.ts new file mode 100644 index 00000000..fdf62310 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager.ts @@ -0,0 +1,21 @@ +import { ReadOnlyTreeNode, TreeNode } from '../../Node/TreeNode'; +import { TreeNodeCollection } from '../NodeCollection/TreeNodeCollection'; +import { SingleNodeFocusManager } from './SingleNodeFocusManager'; + +export class SingleNodeCollectionFocusManager implements SingleNodeFocusManager { + public get currentSingleFocusedNode(): TreeNode | undefined { + const focusedNodes = this.collection.nodes.flattenedNodes.filter( + (node) => node.state.current.isFocused, + ); + return focusedNodes.length === 1 ? focusedNodes[0] : undefined; + } + + public setSingleFocus(focusedNode: ReadOnlyTreeNode): void { + this.collection.nodes.flattenedNodes.forEach((node) => { + const isFocused = node === focusedNode; + node.state.commitTransaction(node.state.beginTransaction().withFocusState(isFocused)); + }); + } + + constructor(private readonly collection: TreeNodeCollection) { } +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeFocusManager.ts b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeFocusManager.ts new file mode 100644 index 00000000..a18de308 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeFocusManager.ts @@ -0,0 +1,6 @@ +import { ReadOnlyTreeNode, TreeNode } from '../../Node/TreeNode'; + +export interface SingleNodeFocusManager { + readonly currentSingleFocusedNode: TreeNode | undefined; + setSingleFocus(focusedNode: ReadOnlyTreeNode): void; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes.ts b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes.ts new file mode 100644 index 00000000..f1822351 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes.ts @@ -0,0 +1,15 @@ +import { ReadOnlyTreeNode, TreeNode } from '../../../Node/TreeNode'; + +export interface ReadOnlyQueryableNodes { + readonly rootNodes: readonly ReadOnlyTreeNode[]; + readonly flattenedNodes: readonly ReadOnlyTreeNode[]; + + getNodeById(id: string): ReadOnlyTreeNode; +} + +export interface QueryableNodes extends ReadOnlyQueryableNodes { + readonly rootNodes: readonly TreeNode[]; + readonly flattenedNodes: readonly TreeNode[]; + + getNodeById(id: string): TreeNode; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/TreeNodeNavigator.ts b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/TreeNodeNavigator.ts new file mode 100644 index 00000000..e7aab8bb --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/TreeNodeNavigator.ts @@ -0,0 +1,28 @@ +import { TreeNode } from '../../../Node/TreeNode'; +import { QueryableNodes } from './QueryableNodes'; + +export class TreeNodeNavigator implements QueryableNodes { + public readonly flattenedNodes: readonly TreeNode[]; + + constructor(public readonly rootNodes: readonly TreeNode[]) { + this.flattenedNodes = flattenNodes(rootNodes); + } + + public getNodeById(id: string): TreeNode { + const foundNode = this.flattenedNodes.find((node) => node.id === id); + if (!foundNode) { + throw new Error(`Node could not be found: ${id}`); + } + return foundNode; + } +} + +function flattenNodes(nodes: readonly TreeNode[]): TreeNode[] { + return nodes.reduce((flattenedNodes, node) => { + flattenedNodes.push(node); + if (node.hierarchy.children) { + flattenedNodes.push(...flattenNodes(node.hierarchy.children)); + } + return flattenedNodes; + }, new Array()); +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser.ts b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser.ts new file mode 100644 index 00000000..3e294c80 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser.ts @@ -0,0 +1,26 @@ +import { TreeInputNodeData } from '../../Bindings/TreeInputNodeData'; +import { TreeNode } from '../../Node/TreeNode'; +import { TreeNodeManager } from '../../Node/TreeNodeManager'; + +export function parseTreeInput( + input: readonly TreeInputNodeData[], +): TreeNode[] { + if (!input) { + throw new Error('missing input'); + } + if (!Array.isArray(input)) { + throw new Error('input data must be an array'); + } + const nodes = input.map((nodeData) => createNode(nodeData)); + return nodes; +} + +function createNode(input: TreeInputNodeData): TreeNode { + const node = new TreeNodeManager(input.id, input.data); + node.hierarchy.setChildren(input.children?.map((child) => { + const childNode = createNode(child); + childNode.hierarchy.setParent(node); + return childNode; + }) ?? []); + return node; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeCollection.ts b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeCollection.ts new file mode 100644 index 00000000..5cf2fc46 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeCollection.ts @@ -0,0 +1,15 @@ +import { IEventSource } from '@/infrastructure/Events/IEventSource'; +import { TreeInputNodeData } from '../../Bindings/TreeInputNodeData'; +import { QueryableNodes, ReadOnlyQueryableNodes } from './Query/QueryableNodes'; + +export interface ReadOnlyTreeNodeCollection { + readonly nodes: ReadOnlyQueryableNodes; + readonly nodesUpdated: IEventSource; + updateRootNodes(rootNodes: readonly TreeInputNodeData[]): void; +} + +export interface TreeNodeCollection extends ReadOnlyTreeNodeCollection { + readonly nodes: QueryableNodes; + readonly nodesUpdated: IEventSource; + updateRootNodes(rootNodes: readonly TreeInputNodeData[]): void; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeInitializerAndUpdater.ts b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeInitializerAndUpdater.ts new file mode 100644 index 00000000..83a8f87e --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeInitializerAndUpdater.ts @@ -0,0 +1,23 @@ +import { EventSource } from '@/infrastructure/Events/EventSource'; +import { TreeInputNodeData } from '../../Bindings/TreeInputNodeData'; +import { TreeNodeCollection } from './TreeNodeCollection'; +import { parseTreeInput } from './TreeInputParser'; +import { TreeNodeNavigator } from './Query/TreeNodeNavigator'; +import { QueryableNodes } from './Query/QueryableNodes'; + +export class TreeNodeInitializerAndUpdater implements TreeNodeCollection { + public nodes: QueryableNodes = new TreeNodeNavigator([]); + + public nodesUpdated = new EventSource(); + + public updateRootNodes(rootNodesData: readonly TreeInputNodeData[]): void { + if (!rootNodesData?.length) { + throw new Error('missing data'); + } + const rootNodes = this.treeNodeParser(rootNodesData); + this.nodes = new TreeNodeNavigator(rootNodes); + this.nodesUpdated.notify(this.nodes); + } + + constructor(private readonly treeNodeParser = parseTreeInput) { } +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot.ts b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot.ts new file mode 100644 index 00000000..7a0000ae --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot.ts @@ -0,0 +1,7 @@ +import { SingleNodeFocusManager } from './Focus/SingleNodeFocusManager'; +import { TreeNodeCollection } from './NodeCollection/TreeNodeCollection'; + +export interface TreeRoot { + readonly collection: TreeNodeCollection; + readonly focus: SingleNodeFocusManager; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot.vue b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot.vue new file mode 100644 index 00000000..4d973672 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRootManager.ts b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRootManager.ts new file mode 100644 index 00000000..7d36c727 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRootManager.ts @@ -0,0 +1,21 @@ +import { TreeRoot } from './TreeRoot'; +import { TreeNodeInitializerAndUpdater } from './NodeCollection/TreeNodeInitializerAndUpdater'; +import { TreeNodeCollection } from './NodeCollection/TreeNodeCollection'; +import { SingleNodeFocusManager } from './Focus/SingleNodeFocusManager'; +import { SingleNodeCollectionFocusManager } from './Focus/SingleNodeCollectionFocusManager'; + +export class TreeRootManager implements TreeRoot { + public readonly collection: TreeNodeCollection; + + public readonly focus: SingleNodeFocusManager; + + constructor( + collection: TreeNodeCollection = new TreeNodeInitializerAndUpdater(), + createFocusManager: ( + collection: TreeNodeCollection + ) => SingleNodeFocusManager = (nodes) => new SingleNodeCollectionFocusManager(nodes), + ) { + this.collection = collection; + this.focus = createFocusManager(this.collection); + } +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/TreeView.vue b/src/presentation/components/Scripts/View/Tree/TreeView/TreeView.vue new file mode 100644 index 00000000..28bb8101 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/TreeView.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateChildrenCheckState.ts b/src/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateChildrenCheckState.ts new file mode 100644 index 00000000..66d62777 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateChildrenCheckState.ts @@ -0,0 +1,44 @@ +import { WatchSource } from 'vue'; +import { TreeRoot } from './TreeRoot/TreeRoot'; +import { useNodeStateChangeAggregator } from './UseNodeStateChangeAggregator'; +import { HierarchyAccess } from './Node/Hierarchy/HierarchyAccess'; +import { TreeNodeCheckState } from './Node/State/CheckState'; + +export function useAutoUpdateChildrenCheckState( + treeWatcher: WatchSource, +) { + const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher); + + onNodeStateChange((node, change) => { + if (change.newState.checkState === change.oldState.checkState) { + return; + } + updateChildrenCheckedState(node.hierarchy, change.newState.checkState); + }); +} + +function updateChildrenCheckedState( + node: HierarchyAccess, + newParentState: TreeNodeCheckState, +) { + if (node.isLeafNode) { + return; + } + if (!shouldUpdateChildren(newParentState)) { + return; + } + const { children } = node; + children.forEach((childNode) => { + if (childNode.state.current.checkState === newParentState) { + return; + } + childNode.state.commitTransaction( + childNode.state.beginTransaction().withCheckState(newParentState), + ); + }); +} + +function shouldUpdateChildren(newParentState: TreeNodeCheckState) { + return newParentState === TreeNodeCheckState.Checked + || newParentState === TreeNodeCheckState.Unchecked; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateParentCheckState.ts b/src/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateParentCheckState.ts new file mode 100644 index 00000000..13be2fbf --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateParentCheckState.ts @@ -0,0 +1,47 @@ +import { WatchSource } from 'vue'; +import { TreeRoot } from './TreeRoot/TreeRoot'; +import { useNodeStateChangeAggregator } from './UseNodeStateChangeAggregator'; +import { HierarchyAccess } from './Node/Hierarchy/HierarchyAccess'; +import { TreeNodeCheckState } from './Node/State/CheckState'; +import { ReadOnlyTreeNode } from './Node/TreeNode'; + +export function useAutoUpdateParentCheckState( + treeWatcher: WatchSource, +) { + const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher); + + onNodeStateChange((node, change) => { + if (change.newState.checkState === change.oldState.checkState) { + return; + } + updateNodeParentCheckedState(node.hierarchy); + }); +} + +function updateNodeParentCheckedState( + node: HierarchyAccess, +) { + const { parent } = node; + if (!parent) { + return; + } + const newState = getNewStateCheckedStateBasedOnChildren(parent); + if (newState === parent.state.current.checkState) { + return; + } + parent.state.commitTransaction( + parent.state.beginTransaction().withCheckState(newState), + ); +} + +function getNewStateCheckedStateBasedOnChildren(node: ReadOnlyTreeNode): TreeNodeCheckState { + const { children } = node.hierarchy; + const childrenStates = children.map((child) => child.state.current.checkState); + if (childrenStates.every((state) => state === TreeNodeCheckState.Unchecked)) { + return TreeNodeCheckState.Unchecked; + } + if (childrenStates.every((state) => state === TreeNodeCheckState.Checked)) { + return TreeNodeCheckState.Checked; + } + return TreeNodeCheckState.Indeterminate; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes.ts b/src/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes.ts new file mode 100644 index 00000000..d08aee11 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes.ts @@ -0,0 +1,27 @@ +import { + WatchSource, watch, inject, readonly, ref, +} from 'vue'; +import { InjectionKeys } from '@/presentation/injectionSymbols'; +import { TreeRoot } from './TreeRoot/TreeRoot'; +import { QueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes'; + +export function useCurrentTreeNodes(treeWatcher: WatchSource) { + const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)(); + + const tree = ref(); + const nodes = ref(); + + watch(treeWatcher, (newTree) => { + tree.value = newTree; + nodes.value = newTree.collection.nodes; + events.unsubscribeAllAndRegister([ + newTree.collection.nodesUpdated.on((newNodes) => { + nodes.value = newNodes; + }), + ]); + }, { immediate: true }); + + return { + nodes: readonly(nodes), + }; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/UseLeafNodeCheckedStateUpdater.ts b/src/presentation/components/Scripts/View/Tree/TreeView/UseLeafNodeCheckedStateUpdater.ts new file mode 100644 index 00000000..1cb58e84 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/UseLeafNodeCheckedStateUpdater.ts @@ -0,0 +1,43 @@ +import { WatchSource, watch } from 'vue'; +import { TreeRoot } from './TreeRoot/TreeRoot'; +import { TreeNode } from './Node/TreeNode'; +import { QueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes'; +import { useCurrentTreeNodes } from './UseCurrentTreeNodes'; +import { TreeNodeCheckState } from './Node/State/CheckState'; + +export function useLeafNodeCheckedStateUpdater( + treeWatcher: WatchSource, + leafNodeIdsWatcher: WatchSource, +) { + const { nodes } = useCurrentTreeNodes(treeWatcher); + + watch( + [leafNodeIdsWatcher, () => nodes.value], + ([nodeIds, actualNodes]) => { + updateNodeSelections(actualNodes, nodeIds); + }, + { immediate: true }, + ); +} + +function updateNodeSelections( + nodes: QueryableNodes, + selectedNodeIds: readonly string[], +) { + nodes.flattenedNodes.forEach((node) => { + updateNodeSelection(node, selectedNodeIds); + }); +} + +function updateNodeSelection( + node: TreeNode, + selectedNodeIds: readonly string[], +) { + if (!node.hierarchy.isLeafNode) { + return; + } + const newState = selectedNodeIds.includes(node.id) + ? TreeNodeCheckState.Checked + : TreeNodeCheckState.Unchecked; + node.state.commitTransaction(node.state.beginTransaction().withCheckState(newState)); +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.ts b/src/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.ts new file mode 100644 index 00000000..70c9a5bc --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.ts @@ -0,0 +1,35 @@ +import { WatchSource, inject, watch } from 'vue'; +import { InjectionKeys } from '@/presentation/injectionSymbols'; +import { TreeRoot } from './TreeRoot/TreeRoot'; +import { TreeNode } from './Node/TreeNode'; +import { useCurrentTreeNodes } from './UseCurrentTreeNodes'; +import { NodeStateChangedEvent } from './Node/State/StateAccess'; + +type NodeStateChangeEventCallback = ( + node: TreeNode, + stateChange: NodeStateChangedEvent, +) => void; + +export function useNodeStateChangeAggregator(treeWatcher: WatchSource) { + const { nodes } = useCurrentTreeNodes(treeWatcher); + const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)(); + + const onNodeChangeCallbacks = new Array(); + + watch(() => nodes.value, (newNodes) => { + events.unsubscribeAll(); + newNodes.flattenedNodes.forEach((node) => { + events.register([ + node.state.changed.on((stateChange) => { + onNodeChangeCallbacks.forEach((callback) => callback(node, stateChange)); + }), + ]); + }); + }); + + return { + onNodeStateChange: ( + callback: NodeStateChangeEventCallback, + ) => onNodeChangeCallbacks.push(callback), + }; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/UseTreeKeyboardNavigation.ts b/src/presentation/components/Scripts/View/Tree/TreeView/UseTreeKeyboardNavigation.ts new file mode 100644 index 00000000..f779c7af --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/UseTreeKeyboardNavigation.ts @@ -0,0 +1,166 @@ +import { onMounted, onUnmounted, Ref } from 'vue'; +import { TreeRoot } from './TreeRoot/TreeRoot'; +import { TreeNodeCheckState } from './Node/State/CheckState'; +import { SingleNodeFocusManager } from './TreeRoot/Focus/SingleNodeFocusManager'; +import { QueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes'; +import { TreeNode } from './Node/TreeNode'; + +type TreeNavigationKeyCodes = 'ArrowLeft' | 'ArrowUp' | 'ArrowRight' | 'ArrowDown' | ' ' | 'Enter'; + +export function useTreeKeyboardNavigation( + treeRoot: TreeRoot, + treeElementRef: Ref, +) { + useKeyboardListener(treeElementRef, (event) => { + if (!treeElementRef.value) { + return; // Not yet initialized? + } + + const keyCode = event.key as TreeNavigationKeyCodes; + + if (!treeRoot.focus.currentSingleFocusedNode) { + return; + } + + const action = KeyToActionMapping[keyCode]; + + if (!action) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + action({ + focus: treeRoot.focus, + nodes: treeRoot.collection.nodes, + }); + }); +} + +function useKeyboardListener( + elementRef: Ref, + handleKeyboardEvent: (event: KeyboardEvent) => void, +) { + onMounted(() => { + elementRef.value?.addEventListener('keydown', handleKeyboardEvent, true); + }); + + onUnmounted(() => { + elementRef.value?.removeEventListener('keydown', handleKeyboardEvent); + }); +} + +interface TreeNavigationContext { + readonly focus: SingleNodeFocusManager; + readonly nodes: QueryableNodes; +} + +const KeyToActionMapping: Record< +TreeNavigationKeyCodes, +(context: TreeNavigationContext) => void +> = { + ArrowLeft: collapseNodeOrFocusParent, + ArrowUp: focusPreviousVisibleNode, + ArrowRight: expandNodeOrFocusFirstChild, + ArrowDown: focusNextVisibleNode, + ' ': toggleTreeNodeCheckStatus, + Enter: toggleTreeNodeCheckStatus, +}; + +function focusPreviousVisibleNode(context: TreeNavigationContext): void { + const previousVisibleNode = findPreviousVisibleNode( + context.focus.currentSingleFocusedNode, + context.nodes, + ); + if (!previousVisibleNode) { + return; + } + context.focus.setSingleFocus(previousVisibleNode); +} + +function focusNextVisibleNode(context: TreeNavigationContext): void { + const focusedNode = context.focus.currentSingleFocusedNode; + const nextVisibleNode = findNextVisibleNode(focusedNode, context.nodes); + if (!nextVisibleNode) { + return; + } + context.focus.setSingleFocus(nextVisibleNode); +} + +function toggleTreeNodeCheckStatus(context: TreeNavigationContext): void { + const focusedNode = context.focus.currentSingleFocusedNode; + const nodeState = focusedNode.state; + let transaction = nodeState.beginTransaction(); + if (nodeState.current.checkState === TreeNodeCheckState.Checked) { + transaction = transaction.withCheckState(TreeNodeCheckState.Unchecked); + } else { + transaction = transaction.withCheckState(TreeNodeCheckState.Checked); + } + nodeState.commitTransaction(transaction); +} + +function collapseNodeOrFocusParent(context: TreeNavigationContext): void { + const focusedNode = context.focus.currentSingleFocusedNode; + const nodeState = focusedNode.state; + const parentNode = focusedNode.hierarchy.parent; + if (focusedNode.hierarchy.isBranchNode && nodeState.current.isExpanded) { + nodeState.commitTransaction( + nodeState.beginTransaction().withExpansionState(false), + ); + } else { + context.focus.setSingleFocus(parentNode); + } +} + +function expandNodeOrFocusFirstChild(context: TreeNavigationContext): void { + const focusedNode = context.focus.currentSingleFocusedNode; + const nodeState = focusedNode.state; + if (focusedNode.hierarchy.isBranchNode && !nodeState.current.isExpanded) { + nodeState.commitTransaction( + nodeState.beginTransaction().withExpansionState(true), + ); + return; + } + if (focusedNode.hierarchy.children.length === 0) { + return; + } + const firstChildNode = focusedNode.hierarchy.children[0]; + if (firstChildNode) { + context.focus.setSingleFocus(firstChildNode); + } +} + +function findNextVisibleNode(node: TreeNode, nodes: QueryableNodes): TreeNode | undefined { + if (node.hierarchy.children.length && node.state.current.isExpanded) { + return node.hierarchy.children[0]; + } + const nextNode = findNextNode(node, nodes); + const parentNode = node.hierarchy.parent; + if (!nextNode && parentNode) { + const nextSibling = findNextNode(parentNode, nodes); + return nextSibling; + } + return nextNode; +} + +function findNextNode(node: TreeNode, nodes: QueryableNodes): TreeNode | undefined { + const index = nodes.flattenedNodes.indexOf(node); + return nodes.flattenedNodes[index + 1] || undefined; +} + +function findPreviousVisibleNode(node: TreeNode, nodes: QueryableNodes): TreeNode | undefined { + const previousNode = findPreviousNode(node, nodes); + if (!previousNode) { + return node.hierarchy.parent; + } + if (previousNode.hierarchy.children.length && previousNode.state.current.isExpanded) { + return previousNode.hierarchy.children[previousNode.hierarchy.children.length - 1]; + } + return previousNode; +} + +function findPreviousNode(node: TreeNode, nodes: QueryableNodes): TreeNode | undefined { + const index = nodes.flattenedNodes.indexOf(node); + return nodes.flattenedNodes[index - 1] || undefined; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/UseTreeQueryFilter.ts b/src/presentation/components/Scripts/View/Tree/TreeView/UseTreeQueryFilter.ts new file mode 100644 index 00000000..4e52455b --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/UseTreeQueryFilter.ts @@ -0,0 +1,204 @@ +import { WatchSource, watch } from 'vue'; +import { TreeViewFilterAction, TreeViewFilterEvent, TreeViewFilterPredicate } from './Bindings/TreeInputFilterEvent'; +import { ReadOnlyTreeNode, TreeNode } from './Node/TreeNode'; +import { TreeRoot } from './TreeRoot/TreeRoot'; +import { useCurrentTreeNodes } from './UseCurrentTreeNodes'; +import { QueryableNodes, ReadOnlyQueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes'; +import { TreeNodeStateTransaction } from './Node/State/StateAccess'; +import { TreeNodeStateDescriptor } from './Node/State/StateDescriptor'; + +export function useTreeQueryFilter( + latestFilterEventWatcher: WatchSource, + treeWatcher: WatchSource, +) { + const { nodes } = useCurrentTreeNodes(treeWatcher); + + let isFiltering = false; + const statesBeforeFiltering = new NodeStateRestorer(); + statesBeforeFiltering.saveStateBeforeFilter(nodes.value); + + setupWatchers({ + filterEventWatcher: latestFilterEventWatcher, + nodesWatcher: () => nodes.value, + onFilterTrigger: (predicate, newNodes) => runFilter( + newNodes, + predicate, + ), + onFilterReset: () => resetFilter(nodes.value), + }); + + function resetFilter(currentNodes: QueryableNodes) { + if (!isFiltering) { + return; + } + isFiltering = false; + currentNodes.flattenedNodes.forEach((node: TreeNode) => { + let transaction = node.state.beginTransaction() + .withMatchState(false); + transaction = statesBeforeFiltering.applyOriginalState(node, transaction); + node.state.commitTransaction(transaction); + }); + statesBeforeFiltering.clear(); + } + + function runFilter(currentNodes: QueryableNodes, predicate: TreeViewFilterPredicate) { + if (!isFiltering) { + statesBeforeFiltering.saveStateBeforeFilter(currentNodes); + isFiltering = true; + } + const { matchedNodes, unmatchedNodes } = partitionNodesByMatchCriteria(currentNodes, predicate); + const nodeTransactions = getNodeChangeTransactions(matchedNodes, unmatchedNodes); + + nodeTransactions.forEach((transaction, node) => { + node.state.commitTransaction(transaction); + }); + } +} + +function getNodeChangeTransactions( + matchedNodes: Iterable, + unmatchedNodes: Iterable, +) { + const transactions = new Map(); + + for (const unmatchedNode of unmatchedNodes) { + addOrUpdateTransaction(unmatchedNode, (builder) => builder + .withVisibilityState(false) + .withMatchState(false)); + } + + for (const matchedNode of matchedNodes) { + addOrUpdateTransaction(matchedNode, (builder) => { + let transaction = builder + .withVisibilityState(true) + .withMatchState(true); + if (matchedNode.hierarchy.isBranchNode) { + transaction = transaction.withExpansionState(false); + } + return transaction; + }); + + traverseAllChildren(matchedNode, (childNode) => { + addOrUpdateTransaction(childNode, (builder) => builder + .withVisibilityState(true)); + }); + + traverseAllParents(matchedNode, (parentNode) => { + addOrUpdateTransaction(parentNode, (builder) => builder + .withVisibilityState(true) + .withExpansionState(true)); + }); + } + + function addOrUpdateTransaction( + node: TreeNode, + builder: (transaction: TreeNodeStateTransaction) => TreeNodeStateTransaction, + ) { + let transaction = transactions.get(node) ?? node.state.beginTransaction(); + transaction = builder(transaction); + transactions.set(node, transaction); + } + + return transactions; +} + +function partitionNodesByMatchCriteria( + currentNodes: QueryableNodes, + predicate: TreeViewFilterPredicate, +) { + const matchedNodes = new Set(); + const unmatchedNodes = new Set(); + currentNodes.flattenedNodes.forEach((node) => { + if (predicate(node)) { + matchedNodes.add(node); + } else { + unmatchedNodes.add(node); + } + }); + return { + matchedNodes, + unmatchedNodes, + }; +} + +function traverseAllParents(node: TreeNode, handler: (node: TreeNode) => void) { + const parentNode = node.hierarchy.parent; + if (parentNode) { + handler(parentNode); + traverseAllParents(parentNode, handler); + } +} + +function traverseAllChildren(node: TreeNode, handler: (node: TreeNode) => void) { + node.hierarchy.children.forEach((childNode) => { + handler(childNode); + traverseAllChildren(childNode, handler); + }); +} + +class NodeStateRestorer { + private readonly originalStates = new Map>(); + + public saveStateBeforeFilter(nodes: ReadOnlyQueryableNodes) { + nodes + .flattenedNodes + .forEach((node) => { + this.originalStates.set(node, { + isExpanded: node.state.current.isExpanded, + isVisible: node.state.current.isVisible, + }); + }); + } + + public applyOriginalState( + node: TreeNode, + transaction: TreeNodeStateTransaction, + ): TreeNodeStateTransaction { + if (!this.originalStates.has(node)) { + return transaction; + } + const originalState = this.originalStates.get(node); + if (originalState.isExpanded !== undefined) { + transaction = transaction.withExpansionState(originalState.isExpanded); + } + transaction = transaction.withVisibilityState(originalState.isVisible); + return transaction; + } + + public clear() { + this.originalStates.clear(); + } +} + +function setupWatchers(options: { + filterEventWatcher: WatchSource, + nodesWatcher: WatchSource, + onFilterReset: () => void, + onFilterTrigger: ( + predicate: TreeViewFilterPredicate, + nodes: QueryableNodes, + ) => void, +}) { + watch( + [ + options.filterEventWatcher, + options.nodesWatcher, + ], + ([filterEvent, nodes]) => { + if (!filterEvent) { + return; + } + switch (filterEvent.action) { + case TreeViewFilterAction.Triggered: + options.onFilterTrigger(filterEvent.predicate, nodes); + break; + case TreeViewFilterAction.Removed: + options.onFilterReset(); + break; + default: + throw new Error(`Unknown action: ${TreeViewFilterAction[filterEvent.action]}`); + } + }, + { immediate: true }, + ); +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/tree-colors.scss b/src/presentation/components/Scripts/View/Tree/TreeView/tree-colors.scss new file mode 100644 index 00000000..8d6bf015 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/tree-colors.scss @@ -0,0 +1,13 @@ +@use "@/presentation/assets/styles/main" as *; + +/* Tree colors, based on global colors */ +$color-tree-bg : $color-primary-darker; +$color-node-arrow : $color-on-primary; +$color-node-fg : $color-on-primary; +$color-node-highlight-bg : $color-primary-dark; +$color-node-checkbox-bg-checked : $color-secondary; +$color-node-checkbox-bg-unchecked : $color-primary-darkest; +$color-node-checkbox-border-checked : $color-secondary; +$color-node-checkbox-border-unchecked : $color-on-primary; +$color-node-checkbox-border-indeterminate : $color-on-primary; +$color-node-checkbox-tick-checked : $color-on-secondary; diff --git a/src/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser.ts b/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter.ts similarity index 80% rename from src/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser.ts rename to src/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter.ts index 37aeb9d1..33efc17a 100644 --- a/src/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser.ts +++ b/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter.ts @@ -1,15 +1,15 @@ import { ICategory, IScript } from '@/domain/ICategory'; import { ICategoryCollection } from '@/domain/ICategoryCollection'; -import { INodeContent, NodeType } from './SelectableTree/Node/INodeContent'; +import { NodeMetadata, NodeType } from '../NodeContent/NodeMetadata'; -export function parseAllCategories(collection: ICategoryCollection): INodeContent[] | undefined { +export function parseAllCategories(collection: ICategoryCollection): NodeMetadata[] | undefined { return createCategoryNodes(collection.actions); } export function parseSingleCategory( categoryId: number, collection: ICategoryCollection, -): INodeContent[] | undefined { +): NodeMetadata[] | undefined { const category = collection.findCategory(categoryId); if (!category) { throw new Error(`Category with id ${categoryId} does not exist`); @@ -21,9 +21,11 @@ export function parseSingleCategory( 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; } @@ -34,22 +36,19 @@ export function getCategoryNodeId(category: ICategory): string { function parseCategoryRecursively( parentCategory: ICategory, -): INodeContent[] { - if (!parentCategory) { - throw new Error('parentCategory is undefined'); - } +): NodeMetadata[] { return [ ...createCategoryNodes(parentCategory.subCategories), ...createScriptNodes(parentCategory.scripts), ]; } -function createScriptNodes(scripts: ReadonlyArray): INodeContent[] { +function createScriptNodes(scripts: ReadonlyArray): NodeMetadata[] { return (scripts || []) .map((script) => convertScriptToNode(script)); } -function createCategoryNodes(categories: ReadonlyArray): INodeContent[] { +function createCategoryNodes(categories: ReadonlyArray): NodeMetadata[] { return (categories || []) .map((category) => ({ category, children: parseCategoryRecursively(category) })) .map((data) => convertCategoryToNode(data.category, data.children)); @@ -57,8 +56,8 @@ function createCategoryNodes(categories: ReadonlyArray): INodeContent function convertCategoryToNode( category: ICategory, - children: readonly INodeContent[], -): INodeContent { + children: readonly NodeMetadata[], +): NodeMetadata { return { id: getCategoryNodeId(category), type: NodeType.Category, @@ -69,7 +68,7 @@ function convertCategoryToNode( }; } -function convertScriptToNode(script: IScript): INodeContent { +function convertScriptToNode(script: IScript): NodeMetadata { return { id: getScriptNodeId(script), type: NodeType.Script, diff --git a/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/TreeNodeMetadataConverter.ts b/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/TreeNodeMetadataConverter.ts new file mode 100644 index 00000000..56b8cd8a --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/TreeNodeMetadataConverter.ts @@ -0,0 +1,33 @@ +import { NodeMetadata } from '../NodeContent/NodeMetadata'; +import { ReadOnlyTreeNode } from '../TreeView/Node/TreeNode'; +import { TreeInputNodeData } from '../TreeView/Bindings/TreeInputNodeData'; + +export function getNodeMetadata( + treeNode: ReadOnlyTreeNode, +): NodeMetadata { + if (!treeNode) { throw new Error('missing tree node'); } + const data = treeNode.metadata as NodeMetadata; + if (!data) { + throw new Error('Provided node does not contain the expected metadata.'); + } + return data; +} + +export function convertToNodeInput(metadata: NodeMetadata): TreeInputNodeData { + if (!metadata) { throw new Error('missing metadata'); } + return { + id: metadata.id, + children: convertChildren(metadata.children, convertToNodeInput), + data: metadata, + }; +} + +function convertChildren( + oldChildren: readonly TOldNode[], + callback: (value: TOldNode) => TNewNode, +): TNewNode[] { + if (!oldChildren || oldChildren.length === 0) { + return []; + } + return oldChildren.map((childNode) => callback(childNode)); +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater.ts b/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater.ts new file mode 100644 index 00000000..4a06901b --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater.ts @@ -0,0 +1,38 @@ +import { inject } from 'vue'; +import { InjectionKeys } from '@/presentation/injectionSymbols'; +import { TreeNodeCheckState } from '../TreeView/Node/State/CheckState'; +import { TreeNodeStateChangedEmittedEvent } from '../TreeView/Bindings/TreeNodeStateChangedEmittedEvent'; + +export function useCollectionSelectionStateUpdater() { + const { modifyCurrentState, currentState } = inject(InjectionKeys.useCollectionState)(); + + const updateNodeSelection = (event: TreeNodeStateChangedEmittedEvent) => { + const { node } = event; + if (node.hierarchy.isBranchNode) { + return; // A category, let TreeView handle this + } + if (event.change.oldState.checkState === event.change.newState.checkState) { + return; + } + if (node.state.current.checkState === TreeNodeCheckState.Checked) { + if (currentState.value.selection.isSelected(node.id)) { + return; + } + modifyCurrentState((state) => { + state.selection.addSelectedScript(node.id, false); + }); + } + if (node.state.current.checkState === TreeNodeCheckState.Unchecked) { + if (!currentState.value.selection.isSelected(node.id)) { + return; + } + modifyCurrentState((state) => { + state.selection.removeSelectedScript(node.id); + }); + } + }; + + return { + updateNodeSelection, + }; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseSelectedScriptNodeIds.ts b/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseSelectedScriptNodeIds.ts new file mode 100644 index 00000000..5d9025cd --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseSelectedScriptNodeIds.ts @@ -0,0 +1,40 @@ +import { + computed, inject, readonly, ref, +} from 'vue'; +import { InjectionKeys } from '@/presentation/injectionSymbols'; +import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; +import { getScriptNodeId } from './CategoryNodeMetadataConverter'; + +export function useSelectedScriptNodeIds(scriptNodeIdParser = getScriptNodeId) { + const { selectedScripts } = useSelectedScripts(); + + const selectedNodeIds = computed(() => { + return selectedScripts + .value + .map((selected) => scriptNodeIdParser(selected.script)); + }); + + return { + selectedScriptNodeIds: readonly(selectedNodeIds), + }; +} + +function useSelectedScripts() { + const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)(); + const { onStateChange } = inject(InjectionKeys.useCollectionState)(); + + const selectedScripts = ref([]); + + onStateChange((state) => { + selectedScripts.value = state.selection.selectedScripts; + events.unsubscribeAllAndRegister([ + state.selection.changed.on((scripts) => { + selectedScripts.value = scripts; + }), + ]); + }, { immediate: true }); + + return { + selectedScripts: readonly(selectedScripts), + }; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewFilterEvent.ts b/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewFilterEvent.ts new file mode 100644 index 00000000..32c977e9 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewFilterEvent.ts @@ -0,0 +1,85 @@ +import { + Ref, inject, readonly, ref, +} from 'vue'; +import { IScript } from '@/domain/IScript'; +import { ICategory } from '@/domain/ICategory'; +import { InjectionKeys } from '@/presentation/injectionSymbols'; +import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter'; +import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; +import { TreeViewFilterEvent, createFilterRemovedEvent, createFilterTriggeredEvent } from '../TreeView/Bindings/TreeInputFilterEvent'; +import { NodeMetadata } from '../NodeContent/NodeMetadata'; +import { ReadOnlyTreeNode } from '../TreeView/Node/TreeNode'; +import { getNodeMetadata } from './TreeNodeMetadataConverter'; +import { getCategoryNodeId, getScriptNodeId } from './CategoryNodeMetadataConverter'; + +type TreeNodeFilterResultPredicate = ( + node: ReadOnlyTreeNode, + filterResult: IFilterResult, +) => boolean; + +export function useTreeViewFilterEvent() { + const { onStateChange } = inject(InjectionKeys.useCollectionState)(); + const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)(); + + const latestFilterEvent = ref(undefined); + + const treeNodePredicate: TreeNodeFilterResultPredicate = (node, filterResult) => filterMatches( + getNodeMetadata(node), + filterResult, + ); + + onStateChange((newState) => { + latestFilterEvent.value = createFilterEvent(newState.filter.currentFilter, treeNodePredicate); + events.unsubscribeAllAndRegister([ + subscribeToFilterChanges(newState.filter, latestFilterEvent, treeNodePredicate), + ]); + }, { immediate: true }); + + return { + latestFilterEvent: readonly(latestFilterEvent), + }; +} + +function subscribeToFilterChanges( + filter: IReadOnlyUserFilter, + latestFilterEvent: Ref, + filterPredicate: TreeNodeFilterResultPredicate, +) { + return filter.filterChanged.on((event) => { + event.visit({ + onApply: (result) => { + latestFilterEvent.value = createFilterTriggeredEvent( + (node) => filterPredicate(node, result), + ); + }, + onClear: () => { + latestFilterEvent.value = createFilterRemovedEvent(); + }, + }); + }); +} + +function createFilterEvent( + filter: IFilterResult | undefined, + filterPredicate: TreeNodeFilterResultPredicate, +): TreeViewFilterEvent { + if (!filter) { + return createFilterRemovedEvent(); + } + return createFilterTriggeredEvent( + (node) => filterPredicate(node, filter), + ); +} + +function filterMatches(node: NodeMetadata, filter: IFilterResult): boolean { + return containsScript(node, filter.scriptMatches) + || containsCategory(node, filter.categoryMatches); +} + +function containsScript(expected: NodeMetadata, scripts: readonly IScript[]) { + return scripts.some((existing: IScript) => expected.id === getScriptNodeId(existing)); +} + +function containsCategory(expected: NodeMetadata, categories: readonly ICategory[]) { + return categories.some((existing: ICategory) => expected.id === getCategoryNodeId(existing)); +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewNodeInput.ts b/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewNodeInput.ts new file mode 100644 index 00000000..81ec916a --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewNodeInput.ts @@ -0,0 +1,53 @@ +import { + WatchSource, computed, inject, + ref, watch, +} from 'vue'; +import { ICategoryCollection } from '@/domain/ICategoryCollection'; +import { InjectionKeys } from '@/presentation/injectionSymbols'; +import { TreeInputNodeData } from '../TreeView/Bindings/TreeInputNodeData'; +import { NodeMetadata } from '../NodeContent/NodeMetadata'; +import { convertToNodeInput } from './TreeNodeMetadataConverter'; +import { parseSingleCategory, parseAllCategories } from './CategoryNodeMetadataConverter'; + +export function useTreeViewNodeInput( + categoryIdWatcher: WatchSource, + parser: CategoryNodeParser = { + parseSingle: parseSingleCategory, + parseAll: parseAllCategories, + }, + nodeConverter = convertToNodeInput, +) { + const { currentState } = inject(InjectionKeys.useCollectionState)(); + + const categoryId = ref(); + + watch(categoryIdWatcher, (newCategoryId) => { + categoryId.value = newCategoryId; + }, { immediate: true }); + + const nodes = computed(() => { + const nodeMetadataList = parseNodes(categoryId.value, currentState.value.collection, parser); + const nodeInputs = nodeMetadataList.map((node) => nodeConverter(node)); + return nodeInputs; + }); + + return { + treeViewInputNodes: nodes, + }; +} + +function parseNodes( + categoryId: number | undefined, + categoryCollection: ICategoryCollection, + parser: CategoryNodeParser, +): NodeMetadata[] { + if (categoryId !== undefined) { + return parser.parseSingle(categoryId, categoryCollection); + } + return parser.parseAll(categoryCollection); +} + +export interface CategoryNodeParser { + readonly parseSingle: typeof parseSingleCategory; + readonly parseAll: typeof parseAllCategories; +} diff --git a/tests/integration/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/MarkdownRenderer.spec.ts b/tests/integration/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer.spec.ts similarity index 95% rename from tests/integration/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/MarkdownRenderer.spec.ts rename to tests/integration/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer.spec.ts index ebafbaa3..acd3f143 100644 --- a/tests/integration/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/MarkdownRenderer.spec.ts +++ b/tests/integration/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { parseApplication } from '@/application/Parser/ApplicationParser'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import { createRenderer } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/MarkdownRenderer'; +import { createRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer'; describe('MarkdownRenderer', () => { describe('can render all docs', () => { diff --git a/tests/integration/presentation/components/Scripts/View/Tree/TreeView/TreeView.spec.ts b/tests/integration/presentation/components/Scripts/View/Tree/TreeView/TreeView.spec.ts new file mode 100644 index 00000000..49519d24 --- /dev/null +++ b/tests/integration/presentation/components/Scripts/View/Tree/TreeView/TreeView.spec.ts @@ -0,0 +1,120 @@ +import { + describe, it, expect, +} from 'vitest'; +import { mount } from '@vue/test-utils'; +import { defineComponent, ref } from 'vue'; +import TreeView from '@/presentation/components/Scripts/View/Tree/TreeView/TreeView.vue'; +import { TreeInputNodeData } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData'; +import { provideDependencies } from '@/presentation/bootstrapping/DependencyProvider'; +import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationContextStub'; + +describe('TreeView', () => { + it('should render all provided root nodes correctly', async () => { + // arrange + const nodes = createSampleNodes(); + const wrapper = createTreeViewWrapper(nodes); + // act + await waitForStableDom(wrapper.element); + // assert + const expectedTotalRootNodes = nodes.length; + expect(wrapper.findAll('.node').length).to.equal(expectedTotalRootNodes, wrapper.html()); + const rootNodeTexts = nodes.map((node) => node.data.label); + rootNodeTexts.forEach((label) => { + expect(wrapper.text()).to.include(label); + }); + }); +}); + +function createTreeViewWrapper(initialNodeData: readonly TreeInputNodeData[]) { + return mount(defineComponent({ + components: { + TreeView, + }, + setup() { + provideDependencies(new ApplicationContextStub()); + + const initialNodes = ref(initialNodeData); + const selectedLeafNodeIds = ref([]); + return { + initialNodes, + selectedLeafNodeIds, + }; + }, + template: ` + + + `, + })); +} + +function createSampleNodes() { + return [ + { + id: 'root1', + data: { + label: 'Root 1', + }, + children: [ + { + id: 'child1', + data: { + label: 'Child 1', + }, + }, + { + id: 'child2', + data: { + label: 'Child 2', + }, + }, + ], + }, + { + id: 'root2', + data: { + label: 'Root 2', + }, + children: [ + { + id: 'child3', + data: { + label: 'Child 3', + }, + }, + ], + }, + ]; +} + +function waitForStableDom(rootElement, timeout = 3000, interval = 200): Promise { + return new Promise((resolve, reject) => { + let lastTimeoutId; + const observer = new MutationObserver(() => { + if (lastTimeoutId) { + clearTimeout(lastTimeoutId); + } + + lastTimeoutId = setTimeout(() => { + observer.disconnect(); + resolve(); + }, interval); + }); + + observer.observe(rootElement, { + attributes: true, + childList: true, + subtree: true, + characterData: true, + }); + + setTimeout(() => { + observer.disconnect(); + reject(new Error('Timeout waiting for DOM to stabilize')); + }, timeout); + }); +} diff --git a/tests/unit/presentation/components/Scripts/View/TheScriptsView.spec.ts b/tests/unit/presentation/components/Scripts/View/TheScriptsView.spec.ts index aa1d2fb8..185e13d0 100644 --- a/tests/unit/presentation/components/Scripts/View/TheScriptsView.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/TheScriptsView.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { Wrapper, shallowMount } from '@vue/test-utils'; import TheScriptsView from '@/presentation/components/Scripts/View/TheScriptsView.vue'; -import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue'; +import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue'; import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue'; import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType'; import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState'; diff --git a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/Documentation/MarkdownRenderer.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer.spec.ts similarity index 98% rename from tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/Documentation/MarkdownRenderer.spec.ts rename to tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer.spec.ts index 9a2e9f2a..14992298 100644 --- a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/Documentation/MarkdownRenderer.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createRenderer } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/MarkdownRenderer'; +import { createRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer'; describe('MarkdownRenderer', () => { describe('createRenderer', () => { diff --git a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/Reverter/CategoryReverter.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Reverter/CategoryReverter.spec.ts similarity index 97% rename from tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/Reverter/CategoryReverter.spec.ts rename to tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Reverter/CategoryReverter.spec.ts index 470b254b..336b3196 100644 --- a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/Reverter/CategoryReverter.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Reverter/CategoryReverter.spec.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { CategoryReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/CategoryReverter'; -import { getCategoryNodeId } from '@/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser'; +import { CategoryReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/CategoryReverter'; import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { UserSelection } from '@/application/Context/State/Selection/UserSelection'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; +import { getCategoryNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter'; describe('CategoryReverter', () => { describe('getState', () => { diff --git a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/Reverter/ReverterFactory.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ReverterFactory.spec.ts similarity index 74% rename from tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/Reverter/ReverterFactory.spec.ts rename to tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ReverterFactory.spec.ts index c8c4761d..62d5d22c 100644 --- a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/Reverter/ReverterFactory.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ReverterFactory.spec.ts @@ -1,12 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { INodeContent, NodeType } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent'; -import { getReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/ReverterFactory'; -import { ScriptReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/ScriptReverter'; -import { CategoryReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/CategoryReverter'; -import { getScriptNodeId, getCategoryNodeId } from '@/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser'; +import { getReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ReverterFactory'; +import { ScriptReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ScriptReverter'; +import { CategoryReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/CategoryReverter'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; +import { getCategoryNodeId, getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter'; +import { NodeType } from '@/application/Parser/NodeValidation/NodeType'; +import { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; describe('ReverterFactory', () => { describe('getReverter', () => { @@ -33,7 +34,7 @@ describe('ReverterFactory', () => { expect(result instanceof ScriptReverter).to.equal(true); }); }); - function getNodeContentStub(nodeId: string, type: NodeType): INodeContent { + function getNodeContentStub(nodeId: string, type: NodeType): NodeMetadata { return { id: nodeId, text: 'text', diff --git a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/Reverter/ScriptReverter.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ScriptReverter.spec.ts similarity index 97% rename from tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/Reverter/ScriptReverter.spec.ts rename to tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ScriptReverter.spec.ts index 25fa3a6c..2de0ffbe 100644 --- a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/Reverter/ScriptReverter.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ScriptReverter.spec.ts @@ -1,12 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { ScriptReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/ScriptReverter'; -import { getScriptNodeId } from '@/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser'; +import { ScriptReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ScriptReverter'; import { UserSelection } from '@/application/Context/State/Selection/UserSelection'; import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub'; +import { getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter'; describe('ScriptReverter', () => { describe('getState', () => { diff --git a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/ToggleSwitch.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/ToggleSwitch.spec.ts similarity index 99% rename from tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/ToggleSwitch.spec.ts rename to tests/unit/presentation/components/Scripts/View/Tree/NodeContent/ToggleSwitch.spec.ts index d33634a2..7cd29770 100644 --- a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/Node/ToggleSwitch.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/ToggleSwitch.spec.ts @@ -4,7 +4,7 @@ import { mount, } from '@vue/test-utils'; import { nextTick, defineComponent } from 'vue'; -import ToggleSwitch from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/ToggleSwitch.vue'; +import ToggleSwitch from '@/presentation/components/Scripts/View/Tree/NodeContent/ToggleSwitch.vue'; const DOM_INPUT_TOGGLE_CHECKBOX_SELECTOR = 'input.toggle-input'; const DOM_INPUT_TOGGLE_LABEL_OFF_SELECTOR = 'span.label-off'; diff --git a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter.spec.ts deleted file mode 100644 index 9ac25d17..00000000 --- a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { ILiquorTreeExistingNode } from 'liquor-tree'; -import { NodeType, INodeContent } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent'; -import { NodePredicateFilter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter'; - -describe('NodePredicateFilter', () => { - it('calls predicate with expected node', () => { - // arrange - const object: ILiquorTreeExistingNode = { - id: 'script', - data: { - text: 'script-text', - type: NodeType.Script, - docs: [], - isReversible: false, - }, - states: undefined, - children: [], - }; - const expected: INodeContent = { - id: 'script', - text: 'script-text', - isReversible: false, - docs: [], - children: [], - type: NodeType.Script, - }; - let actual: INodeContent; - const predicate = (node: INodeContent) => { actual = node; return true; }; - const sut = new NodePredicateFilter(predicate); - // act - sut.matcher('nop query', object); - // assert - expect(actual).to.deep.equal(expected); - }); - describe('returns result from the predicate', () => { - for (const expected of [false, true]) { - it(expected.toString(), () => { - // arrange - const sut = new NodePredicateFilter(() => expected); - // act - const actual = sut.matcher('nop query', getExistingNode()); - // assert - expect(actual).to.equal(expected); - }); - } - }); -}); - -function getExistingNode(): ILiquorTreeExistingNode { - return { - id: 'script', - data: { - text: 'script-text', - type: NodeType.Script, - docs: [], - isReversible: false, - }, - states: undefined, - children: [], - }; -} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater.spec.ts deleted file mode 100644 index ffcb40b0..00000000 --- a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater.spec.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { ILiquorTreeNode } from 'liquor-tree'; -import { NodeType } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent'; -import { getNewState } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater'; - -describe('NodeStateUpdater', () => { - describe('getNewState', () => { - describe('checked', () => { - describe('script node', () => { - it('true when selected', () => { - // arrange - const node = getScriptNode(); - const selectedScriptNodeIds = ['a', 'b', node.id, 'c']; - // act - const state = getNewState(node, selectedScriptNodeIds); - // assert - expect(state.checked).to.equal(true); - }); - it('false when unselected', () => { - // arrange - const node = getScriptNode(); - const selectedScriptNodeIds = ['a', 'b', 'c']; - // act - const state = getNewState(node, selectedScriptNodeIds); - // assert - expect(state.checked).to.equal(false); - }); - }); - describe('category node', () => { - it('true when every child selected', () => { - // arrange - const node = { - id: '1', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [ - { - id: '2', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [getScriptNode('a'), getScriptNode('b')], - }, - { - id: '3', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [getScriptNode('c')], - }, - ], - }; - const selectedScriptNodeIds = ['a', 'b', 'c']; - // act - const state = getNewState(node, selectedScriptNodeIds); - // assert - expect(state.checked).to.equal(true); - }); - it('false when none of the children is selected', () => { - // arrange - const node = { - id: '1', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [ - { - id: '2', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [getScriptNode('a'), getScriptNode('b')], - }, - { - id: '3', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [getScriptNode('c')], - }, - ], - }; - const selectedScriptNodeIds = ['none', 'of', 'them', 'are', 'selected']; - // act - const state = getNewState(node, selectedScriptNodeIds); - // assert - expect(state.checked).to.equal(false); - }); - it('false when some of the children is selected', () => { - // arrange - const node = { - id: '1', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [ - { - id: '2', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [getScriptNode('a'), getScriptNode('b')], - }, - { - id: '3', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [getScriptNode('c')], - }, - ], - }; - const selectedScriptNodeIds = ['a', 'c', 'unrelated']; - // act - const state = getNewState(node, selectedScriptNodeIds); - // assert - expect(state.checked).to.equal(false); - }); - }); - }); - describe('indeterminate', () => { - describe('script node', () => { - it('false when selected', () => { - // arrange - const node = getScriptNode(); - const selectedScriptNodeIds = ['a', 'b', node.id, 'c']; - // act - const state = getNewState(node, selectedScriptNodeIds); - // assert - expect(state.indeterminate).to.equal(false); - }); - it('false when not selected', () => { - // arrange - const node = getScriptNode(); - const selectedScriptNodeIds = ['a', 'b', 'c']; - // act - const state = getNewState(node, selectedScriptNodeIds); - // assert - expect(state.indeterminate).to.equal(false); - }); - }); - describe('category node', () => { - it('false when all children are selected', () => { - // arrange - const node = { - id: '1', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [ - { - id: '2', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [getScriptNode('a'), getScriptNode('b')], - }, - { - id: '3', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [getScriptNode('c')], - }, - ], - }; - const selectedScriptNodeIds = ['a', 'b', 'c']; - // act - const state = getNewState(node, selectedScriptNodeIds); - // assert - expect(state.indeterminate).to.equal(false); - }); - it('true when all some are selected', () => { - // arrange - const node = { - id: '1', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [ - { - id: '2', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [getScriptNode('a'), getScriptNode('b')], - }, - { - id: '3', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [getScriptNode('c')], - }, - ], - }; - const selectedScriptNodeIds = ['a']; - // act - const state = getNewState(node, selectedScriptNodeIds); - // assert - expect(state.indeterminate).to.equal(true); - }); - it('false when no children are selected', () => { - // arrange - const node = { - id: '1', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [ - { - id: '2', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [getScriptNode('a'), getScriptNode('b')], - }, - { - id: '3', - data: { type: NodeType.Category, docs: [], isReversible: false }, - children: [getScriptNode('c')], - }, - ], - }; - const selectedScriptNodeIds = ['none', 'of', 'them', 'are', 'selected']; - // act - const state = getNewState(node, selectedScriptNodeIds); - // assert - expect(state.indeterminate).to.equal(false); - }); - }); - }); - }); - function getScriptNode(scriptNodeId = 'script'): ILiquorTreeNode { - return { - id: scriptNodeId, - data: { - type: NodeType.Script, - docs: [], - isReversible: false, - }, - children: [], - }; - } -}); diff --git a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator.spec.ts deleted file mode 100644 index 357429a0..00000000 --- a/tests/unit/presentation/components/Scripts/View/Tree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - ILiquorTreeExistingNode, ILiquorTreeNewNode, ILiquorTreeNodeData, ICustomLiquorTreeData, -} from 'liquor-tree'; -import { NodeType, INodeContent } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent'; -import { convertExistingToNode, toNewLiquorTreeNode } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator'; - -describe('NodeTranslator', () => { - it('convertExistingToNode', () => { - // arrange - const existingNode = getExistingNode(); - const expected = getNode(); - // act - const actual = convertExistingToNode(existingNode); - // assert - expect(actual).to.deep.equal(expected); - }); - it('toNewLiquorTreeNode', () => { - // arrange - const node = getNode(); - const expected = getNewNode(); - // act - const actual = toNewLiquorTreeNode(node); - // assert - expect(actual).to.deep.equal(expected); - }); -}); - -function getNode(): INodeContent { - return { - id: '1', - text: 'parentcategory', - isReversible: true, - type: NodeType.Category, - docs: ['parentcategory-doc1', 'parentcategory-doc2'], - children: [ - { - id: '2', - text: 'subcategory', - isReversible: true, - docs: ['subcategory-doc1', 'subcategory-doc2'], - type: NodeType.Category, - children: [ - { - id: 'script1', - text: 'cool script 1', - isReversible: true, - docs: ['script1-doc1', 'script1-doc2'], - children: [], - type: NodeType.Script, - }, - { - id: 'script2', - text: 'cool script 2', - isReversible: true, - docs: ['script2-doc1', 'script2-doc2'], - children: [], - type: NodeType.Script, - }], - }], - }; -} - -function getExpectedExistingNodeData(node: INodeContent): ILiquorTreeNodeData { - return { - text: node.text, - type: node.type, - docs: node.docs, - isReversible: node.isReversible, - }; -} - -function getExpectedNewNodeData(node: INodeContent): ICustomLiquorTreeData { - return { - type: node.type, - docs: node.docs, - isReversible: node.isReversible, - }; -} - -function getExistingNode(): ILiquorTreeExistingNode { - const base = getNode(); - return { - id: base.id, - data: getExpectedExistingNodeData(base), - states: undefined, - children: [ - { - id: base.children[0].id, - data: getExpectedExistingNodeData(base.children[0]), - states: undefined, - children: [ - { - id: base.children[0].children[0].id, - data: getExpectedExistingNodeData(base.children[0].children[0]), - states: undefined, - children: [], - }, - { - id: base.children[0].children[1].id, - data: getExpectedExistingNodeData(base.children[0].children[1]), - states: undefined, - children: [], - }], - }], - }; -} - -function getNewNode(): ILiquorTreeNewNode { - const base = getNode(); - const commonState = { - checked: false, - indeterminate: false, - }; - return { - id: base.id, - text: base.text, - data: getExpectedNewNodeData(base), - state: commonState, - children: [ - { - id: base.children[0].id, - text: base.children[0].text, - data: getExpectedNewNodeData(base.children[0]), - state: commonState, - children: [ - { - id: base.children[0].children[0].id, - text: base.children[0].children[0].text, - data: getExpectedNewNodeData(base.children[0].children[0]), - state: commonState, - children: [], - }, - { - id: base.children[0].children[1].id, - text: base.children[0].children[1].text, - data: getExpectedNewNodeData(base.children[0].children[1]), - state: commonState, - children: [], - }], - }], - }; -} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeViewFilterEvent.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeViewFilterEvent.spec.ts new file mode 100644 index 00000000..933fd277 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeViewFilterEvent.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { + TreeViewFilterAction, TreeViewFilterEvent, TreeViewFilterPredicate, + createFilterRemovedEvent, createFilterTriggeredEvent, +} from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputFilterEvent'; + +describe('TreeViewFilterEvent', () => { + describe('createFilterTriggeredEvent', () => { + it('returns expected action', () => { + // arrange + const expectedAction = TreeViewFilterAction.Triggered; + // act + const event = createFilterTriggeredEvent(createPredicateStub()); + // expect + expect(event.action).to.equal(expectedAction); + }); + describe('returns expected predicate', () => { + const testCases: ReadonlyArray<{ + readonly name: string, + readonly givenPredicate: TreeViewFilterPredicate, + }> = [ + { + name: 'given a real predicate', + givenPredicate: createPredicateStub(), + }, + { + name: 'given undefined predicate', + givenPredicate: undefined, + }, + ]; + testCases.forEach(({ name, givenPredicate }) => { + it(name, () => { + // arrange + const expectedPredicate = givenPredicate; + // act + const event = createFilterTriggeredEvent(expectedPredicate); + // assert + expect(event.predicate).to.equal(expectedPredicate); + }); + }); + }); + it('returns event even without predicate', () => { + // act + const predicate = null as TreeViewFilterPredicate; + // assert + const event = createFilterTriggeredEvent(predicate); + // expect + expect(event.predicate).to.equal(predicate); + }); + it('returns unique timestamp', () => { + // arrange + const instances = new Array(); + // act + instances.push( + createFilterTriggeredEvent(createPredicateStub()), + createFilterTriggeredEvent(createPredicateStub()), + createFilterTriggeredEvent(createPredicateStub()), + ); + // assert + const uniqueDates = new Set(instances.map((instance) => instance.timestamp)); + expect(uniqueDates).to.have.length(instances.length); + }); + }); + + describe('createFilterRemovedEvent', () => { + it('returns expected action', () => { + // arrange + const expectedAction = TreeViewFilterAction.Removed; + // act + const event = createFilterRemovedEvent(); + // expect + expect(event.action).to.equal(expectedAction); + }); + it('returns without predicate', () => { + // arrange + const expected = undefined; + // act + const event = createFilterRemovedEvent(); + // assert + expect(event.predicate).to.equal(expected); + }); + it('returns unique timestamp', () => { + // arrange + const instances = new Array(); + // act + instances.push( + createFilterRemovedEvent(), + createFilterRemovedEvent(), + createFilterRemovedEvent(), + ); + // assert + const uniqueDates = new Set(instances.map((instance) => instance.timestamp)); + expect(uniqueDates).to.have.length(instances.length); + }); + }); +}); + +function createPredicateStub(): TreeViewFilterPredicate { + return () => true; +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/TreeNodeHierarchy.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/TreeNodeHierarchy.spec.ts new file mode 100644 index 00000000..d9a312c1 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/TreeNodeHierarchy.spec.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { TreeNodeHierarchy } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/TreeNodeHierarchy'; +import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode'; +import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub'; +import { HierarchyAccessStub } from '@tests/unit/shared/Stubs/HierarchyAccessStub'; + +describe('TreeNodeHierarchy', () => { + describe('setChildren', () => { + it('sets the provided children correctly', () => { + // arrange + const hierarchy = new TreeNodeHierarchy(); + const expectedChildren = [new TreeNodeStub(), new TreeNodeStub()]; + // act + hierarchy.setChildren(expectedChildren); + // assert + expect(hierarchy.children).to.deep.equal(expectedChildren); + }); + }); + + describe('setParent', () => { + it('sets the provided children correctly', () => { + // arrange + const hierarchy = new TreeNodeHierarchy(); + const expectedParent = new TreeNodeStub(); + // act + hierarchy.setParent(expectedParent); + // assert + expect(hierarchy.parent).to.deep.equal(expectedParent); + }); + }); + + describe('isLeafNode', () => { + it('returns `true` without children', () => { + // arrange + const hierarchy = new TreeNodeHierarchy(); + const children = []; + // act + hierarchy.setChildren(children); + // assert + expect(hierarchy.isLeafNode).to.equal(true); + }); + + it('returns `false` with children', () => { + // arrange + const hierarchy = new TreeNodeHierarchy(); + const children = [new TreeNodeStub()]; + // act + hierarchy.setChildren(children); + // assert + expect(hierarchy.isLeafNode).to.equal(false); + }); + }); + + describe('isBranchNode', () => { + it('returns `false` without children', () => { + // arrange + const hierarchy = new TreeNodeHierarchy(); + const children = []; + // act + hierarchy.setChildren(children); + // assert + expect(hierarchy.isBranchNode).to.equal(false); + }); + + it('returns `true` with children', () => { + // arrange + const hierarchy = new TreeNodeHierarchy(); + const children = [new TreeNodeStub()]; + // act + hierarchy.setChildren(children); + // assert + expect(hierarchy.isBranchNode).to.equal(true); + }); + }); + + describe('depthInTree', () => { + interface DepthTestScenario { + readonly parentNode: TreeNode, + readonly expectedDepth: number; + } + const testCases: readonly DepthTestScenario[] = [ + { + parentNode: undefined, + expectedDepth: 0, + }, + { + parentNode: new TreeNodeStub() + .withHierarchy( + new HierarchyAccessStub() + .withDepthInTree(0) + .withParent(undefined), + ), + expectedDepth: 1, + }, + { + parentNode: new TreeNodeStub().withHierarchy( + new HierarchyAccessStub() + .withDepthInTree(1) + .withParent(new TreeNodeStub()), + ), + expectedDepth: 2, + }, + ]; + testCases.forEach(({ parentNode, expectedDepth }) => { + it(`when depth is expected to be ${expectedDepth}`, () => { + // arrange + const hierarchy = new TreeNodeHierarchy(); + // act + hierarchy.setParent(parentNode); + // assert + expect(hierarchy.depthInTree).to.equal(expectedDepth); + }); + }); + }); +}); diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeState.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeState.spec.ts new file mode 100644 index 00000000..5026b420 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeState.spec.ts @@ -0,0 +1,174 @@ +import { expect } from 'vitest'; +import { TreeNodeState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeState'; +import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor'; +import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState'; +import { NodeStateChangedEvent, TreeNodeStateTransaction } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess'; +import { PropertyKeys } from '@/TypeHelpers'; + +describe('TreeNodeState', () => { + describe('beginTransaction', () => { + it('begins empty transaction', () => { + // arrange + const treeNodeState = new TreeNodeState(); + // act + const transaction = treeNodeState.beginTransaction(); + // assert + expect(Object.keys(transaction.updatedState)).to.have.lengthOf(0); + }); + }); + describe('commitTransaction', () => { + it('should update the current state with transaction changes', () => { + // arrange + const treeNodeState = new TreeNodeState(); + const initialState: TreeNodeStateDescriptor = treeNodeState.current; + const transaction = treeNodeState + .beginTransaction() + .withCheckState(TreeNodeCheckState.Checked); + // act + treeNodeState.commitTransaction(transaction); + // assert + expect(treeNodeState.current.checkState).to.equal(TreeNodeCheckState.Checked); + expect(treeNodeState.current.isExpanded).to.equal(initialState.isExpanded); + }); + + it('should notify when state changes', () => { + // arrange + const treeNodeState = new TreeNodeState(); + const transaction = treeNodeState + .beginTransaction() + .withCheckState(TreeNodeCheckState.Checked); + let notifiedEvent: NodeStateChangedEvent; + // act + treeNodeState.changed.on((event) => { + notifiedEvent = event; + }); + treeNodeState.commitTransaction(transaction); + // assert + expect(notifiedEvent).to.not.equal(undefined); + expect(notifiedEvent.oldState.checkState).to.equal(TreeNodeCheckState.Unchecked); + expect(notifiedEvent.newState.checkState).to.equal(TreeNodeCheckState.Checked); + }); + + it('should not notify when state does not change', () => { + // arrange + const treeNodeState = new TreeNodeState(); + const currentState = treeNodeState.current; + let transaction = treeNodeState + .beginTransaction(); + const updateActions: { + readonly [K in PropertyKeys]: ( + describer: TreeNodeStateTransaction, + ) => TreeNodeStateTransaction; + } = { + checkState: (describer) => describer.withCheckState(currentState.checkState), + isExpanded: (describer) => describer.withExpansionState(currentState.isExpanded), + isFocused: (describer) => describer.withFocusState(currentState.isFocused), + isVisible: (describer) => describer.withVisibilityState(currentState.isVisible), + isMatched: (describer) => describer.withMatchState(currentState.isMatched), + }; + Object.values(updateActions).forEach((updateTransaction) => { + transaction = updateTransaction(transaction); + }); + let isNotified = false; + // act + treeNodeState.changed.on(() => { + isNotified = true; + }); + treeNodeState.commitTransaction(transaction); + // assert + expect(isNotified).to.equal(false); + }); + }); + + describe('toggleCheck', () => { + describe('transitions state as expected', () => { + interface ToggleCheckTestScenario { + readonly initialState: TreeNodeCheckState; + readonly expectedState: TreeNodeCheckState; + } + const testCases: readonly ToggleCheckTestScenario[] = [ + { + initialState: TreeNodeCheckState.Unchecked, + expectedState: TreeNodeCheckState.Checked, + }, + { + initialState: TreeNodeCheckState.Checked, + expectedState: TreeNodeCheckState.Unchecked, + }, + { + initialState: TreeNodeCheckState.Indeterminate, + expectedState: TreeNodeCheckState.Unchecked, + }, + ]; + testCases.forEach(({ initialState, expectedState }) => { + it(`should toggle checkState from ${TreeNodeCheckState[initialState]} to ${TreeNodeCheckState[expectedState]}`, () => { + // arrange + const treeNodeState = new TreeNodeState(); + treeNodeState.commitTransaction( + treeNodeState.beginTransaction().withCheckState(initialState), + ); + // act + treeNodeState.toggleCheck(); + // assert + expect(treeNodeState.current.checkState).to.equal(expectedState); + }); + }); + }); + it('should emit changed event', () => { + // arrange + const treeNodeState = new TreeNodeState(); + let isNotified = false; + treeNodeState.changed.on(() => { + isNotified = true; + }); + // act + treeNodeState.toggleCheck(); + // assert + expect(isNotified).to.equal(true); + }); + }); + + describe('toggleExpand', () => { + describe('transitions state as expected', () => { + interface ToggleExpandTestScenario { + readonly initialState: boolean; + readonly expectedState: boolean; + } + const testCases: readonly ToggleExpandTestScenario[] = [ + { + initialState: true, + expectedState: false, + }, + { + initialState: false, + expectedState: true, + }, + ]; + testCases.forEach(({ initialState, expectedState }) => { + it(`should toggle isExpanded from ${initialState} to ${expectedState}`, () => { + // arrange + const treeNodeState = new TreeNodeState(); + treeNodeState.commitTransaction( + treeNodeState.beginTransaction().withExpansionState(initialState), + ); + // act + treeNodeState.toggleExpand(); + // assert + expect(treeNodeState.current.isExpanded).to.equal(expectedState); + }); + }); + }); + it('should emit changed event', () => { + // arrange + const treeNodeState = new TreeNodeState(); + let isNotified = false; + treeNodeState.changed.on(() => { + isNotified = true; + }); + // act + treeNodeState.toggleExpand(); + // assert + expect(isNotified).to.equal(true); + }); + }); +}); diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeStateTransactionDescriber.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeStateTransactionDescriber.spec.ts new file mode 100644 index 00000000..044f8660 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeStateTransactionDescriber.spec.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { TreeNodeStateTransactionDescriber } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeStateTransactionDescriber'; +import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor'; +import { PropertyKeys } from '@/TypeHelpers'; +import { TreeNodeStateTransaction } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess'; +import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState'; + +describe('TreeNodeStateTransactionDescriber', () => { + describe('sets state as expected', () => { + const testScenarios: { + readonly [K in PropertyKeys]: { + readonly applyStateChange: ( + describer: TreeNodeStateTransaction, + ) => TreeNodeStateTransaction, + readonly extractStateValue: (descriptor: Partial) => unknown, + readonly expectedValue: unknown, + } + } = { + checkState: { + applyStateChange: (describer) => describer.withCheckState(TreeNodeCheckState.Indeterminate), + extractStateValue: (descriptor) => descriptor.checkState, + expectedValue: TreeNodeCheckState.Indeterminate, + }, + isExpanded: { + applyStateChange: (describer) => describer.withExpansionState(true), + extractStateValue: (descriptor) => descriptor.isExpanded, + expectedValue: true, + }, + isVisible: { + applyStateChange: (describer) => describer.withVisibilityState(true), + extractStateValue: (descriptor) => descriptor.isVisible, + expectedValue: true, + }, + isMatched: { + applyStateChange: (describer) => describer.withMatchState(true), + extractStateValue: (descriptor) => descriptor.isMatched, + expectedValue: true, + }, + isFocused: { + applyStateChange: (describer) => describer.withFocusState(true), + extractStateValue: (descriptor) => descriptor.isFocused, + expectedValue: true, + }, + }; + describe('sets single state as expected', () => { + Object.entries(testScenarios).forEach(([stateKey, { + applyStateChange, extractStateValue, expectedValue, + }]) => { + it(stateKey, () => { + // arrange + let describer: TreeNodeStateTransaction = new TreeNodeStateTransactionDescriber(); + // act + describer = applyStateChange(describer); + // assert + const actualValue = extractStateValue(describer.updatedState); + expect(actualValue).to.equal(expectedValue); + }); + }); + }); + it('chains multiple state setters correctly', () => { + // arrange + let describer: TreeNodeStateTransaction = new TreeNodeStateTransactionDescriber(); + // act + Object.values(testScenarios).forEach(({ applyStateChange }) => { + describer = applyStateChange(describer); + }); + // assert + Object.values(testScenarios).forEach(({ extractStateValue, expectedValue }) => { + const actualValue = extractStateValue(describer.updatedState); + expect(actualValue).to.equal(expectedValue); + }); + }); + }); +}); diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNodeManager.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNodeManager.spec.ts new file mode 100644 index 00000000..48e170c6 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNodeManager.spec.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { TreeNodeManager } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNodeManager'; +import { itEachAbsentObjectValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { TreeNodeHierarchy } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/TreeNodeHierarchy'; +import { TreeNodeState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeState'; + +describe('TreeNodeManager', () => { + describe('constructor', () => { + describe('id', () => { + it('should initialize with the provided id', () => { + // arrange + const expectedId = 'test-id'; + // act + const node = new TreeNodeManager(expectedId); + // assert + expect(node.id).to.equal(expectedId); + }); + describe('should throw an error if id is not provided', () => { + itEachAbsentStringValue((absentId) => { + // arrange + const expectedError = 'missing id'; + // act + const act = () => new TreeNodeManager(absentId); + // assert + expect(act).to.throw(expectedError); + }); + }); + }); + + describe('metadata', () => { + it('should initialize with the provided metadata', () => { + // arrange + const expectedMetadata = { key: 'value' }; + // act + const node = new TreeNodeManager('id', expectedMetadata); + // assert + expect(node.metadata).to.equal(expectedMetadata); + }); + describe('should accept absent metadata', () => { + itEachAbsentObjectValue((absentMetadata) => { + // arrange + const expectedMetadata = absentMetadata; + // act + const node = new TreeNodeManager('id', expectedMetadata); + // assert + expect(node.metadata).to.equal(absentMetadata); + }); + }); + }); + + describe('hierarchy', () => { + it(`should initialize as an instance of ${TreeNodeHierarchy.name}`, () => { + // arrange + const expectedType = TreeNodeHierarchy; + // act + const node = new TreeNodeManager('id'); + // assert + expect(node.hierarchy).to.be.an.instanceOf(expectedType); + }); + }); + + describe('state', () => { + it(`should initialize as an instance of ${TreeNodeState.name}`, () => { + // arrange + const expectedType = TreeNodeState; + // act + const node = new TreeNodeManager('id'); + // assert + expect(node.state).to.be.an.instanceOf(expectedType); + }); + }); + }); +}); diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState.spec.ts new file mode 100644 index 00000000..0ea99a3b --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState.spec.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import { defineComponent, nextTick } from 'vue'; +import { + WindowWithEventListeners, useKeyboardInteractionState, +} from '@/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState'; + +describe('useKeyboardInteractionState', () => { + describe('isKeyboardBeingUsed', () => { + it('should initialize as `false`', () => { + // arrange + const { windowStub } = createWindowStub(); + // act + const { returnObject } = mountWrapperComponent(windowStub); + // assert + expect(returnObject.isKeyboardBeingUsed.value).to.equal(false); + }); + + it('should set to `true` on keydown event', () => { + // arrange + const { triggerEvent, windowStub } = createWindowStub(); + // act + const { returnObject } = mountWrapperComponent(windowStub); + triggerEvent('keydown'); + // assert + expect(returnObject.isKeyboardBeingUsed.value).to.equal(true); + }); + + it('should stay `false` on click event', () => { + // arrange + const { triggerEvent, windowStub } = createWindowStub(); + // act + const { returnObject } = mountWrapperComponent(windowStub); + triggerEvent('click'); + // assert + expect(returnObject.isKeyboardBeingUsed.value).to.equal(false); + }); + + it('should transition to `false` on click event after keydown event', () => { + // arrange + const { triggerEvent, windowStub } = createWindowStub(); + // act + const { returnObject } = mountWrapperComponent(windowStub); + triggerEvent('keydown'); + triggerEvent('click'); + // assert + expect(returnObject.isKeyboardBeingUsed.value).to.equal(false); + }); + }); + + describe('attach/detach', () => { + it('should attach keydown and click events on mounted', () => { + // arrange + const { listeners, windowStub } = createWindowStub(); + // act + mountWrapperComponent(windowStub); + // assert + expect(listeners.keydown).to.have.lengthOf(1); + expect(listeners.click).to.have.lengthOf(1); + }); + + it('should detach keydown and click events on unmounted', async () => { + // arrange + const { listeners, windowStub } = createWindowStub(); + // act + const { wrapper } = mountWrapperComponent(windowStub); + wrapper.destroy(); + await nextTick(); + // assert + expect(listeners.keydown).to.have.lengthOf(0); + expect(listeners.click).to.have.lengthOf(0); + }); + }); +}); + +function mountWrapperComponent(window: WindowWithEventListeners) { + let returnObject: ReturnType; + const wrapper = shallowMount(defineComponent({ + setup() { + returnObject = useKeyboardInteractionState(window); + }, + template: '
', + })); + return { + returnObject, + wrapper, + }; +} + +type EventListenerWindowFunction = (ev: Event) => unknown; +type WindowEventKey = keyof WindowEventMap; + +function createWindowStub() { + const listeners: Partial> = {}; + const windowStub: WindowWithEventListeners = { + addEventListener: (eventName: string, fn: EventListenerWindowFunction) => { + if (!listeners[eventName]) { + listeners[eventName] = []; + } + listeners[eventName].push(fn); + }, + removeEventListener: (eventName: string, fn: EventListenerWindowFunction) => { + if (!listeners[eventName]) return; + const index = listeners[eventName].indexOf(fn); + if (index > -1) { + listeners[eventName].splice(index, 1); + } + }, + }; + return { + windowStub, + triggerEvent: (eventName: WindowEventKey) => { + listeners[eventName]?.forEach((fn) => fn(new Event(eventName))); + }, + listeners, + }; +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/UseNodeState.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/UseNodeState.spec.ts new file mode 100644 index 00000000..69d7dfe5 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Node/UseNodeState.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { + ref, defineComponent, WatchSource, nextTick, +} from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { ReadOnlyTreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode'; +import { useNodeState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/UseNodeState'; +import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub'; +import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub'; +import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub'; +import { NodeStateChangedEventStub } from '@tests/unit/shared/Stubs/NodeStateChangedEventStub'; +import { InjectionKeys } from '@/presentation/injectionSymbols'; +import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub'; + +describe('useNodeState', () => { + it('should set state on immediate invocation if node exists', () => { + // arrange + const expectedState = new TreeNodeStateDescriptorStub(); + const nodeWatcher = ref(undefined); + nodeWatcher.value = new TreeNodeStub() + .withState(new TreeNodeStateAccessStub().withCurrent(expectedState)); + // act + const { returnObject } = mountWrapperComponent(nodeWatcher); + // assert + expect(returnObject.state.value).to.equal(expectedState); + }); + + it('should not set state on immediate invocation if node is undefined', () => { + // arrange + const nodeWatcher = ref(undefined); + // act + const { returnObject } = mountWrapperComponent(nodeWatcher); + // assert + expect(returnObject.state.value).toBeUndefined(); + }); + + it('should update state when nodeWatcher changes', async () => { + // arrange + const expectedNewState = new TreeNodeStateDescriptorStub(); + const nodeWatcher = ref(undefined); + const { returnObject } = mountWrapperComponent(nodeWatcher); + // act + nodeWatcher.value = new TreeNodeStub() + .withState(new TreeNodeStateAccessStub().withCurrent(expectedNewState)); + await nextTick(); + // assert + expect(returnObject.state.value).to.equal(expectedNewState); + }); + + it('should update state when node state changes', () => { + // arrange + const nodeWatcher = ref(undefined); + const stateAccessStub = new TreeNodeStateAccessStub(); + const expectedChangedState = new TreeNodeStateDescriptorStub(); + nodeWatcher.value = new TreeNodeStub() + .withState(stateAccessStub); + + // act + const { returnObject } = mountWrapperComponent(nodeWatcher); + stateAccessStub.triggerStateChangedEvent( + new NodeStateChangedEventStub() + .withNewState(expectedChangedState), + ); + + // assert + expect(returnObject.state.value).to.equal(expectedChangedState); + }); +}); + +function mountWrapperComponent(nodeWatcher: WatchSource) { + let returnObject: ReturnType; + const wrapper = shallowMount( + defineComponent({ + setup() { + returnObject = useNodeState(nodeWatcher); + }, + template: '
', + }), + { + provide: { + [InjectionKeys.useAutoUnsubscribedEvents as symbol]: + () => new UseAutoUnsubscribedEventsStub().get(), + }, + }, + ); + return { + wrapper, + returnObject, + }; +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager.spec.ts new file mode 100644 index 00000000..61bf62fa --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager.spec.ts @@ -0,0 +1,92 @@ +import { expect, describe, it } from 'vitest'; +import { SingleNodeCollectionFocusManager } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager'; +import { TreeNodeCollectionStub } from '@tests/unit/shared/Stubs/TreeNodeCollectionStub'; +import { QueryableNodesStub } from '@tests/unit/shared/Stubs/QueryableNodesStub'; +import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub'; +import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub'; +import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub'; +import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode'; + +describe('SingleNodeCollectionFocusManager', () => { + describe('currentSingleFocusedNode', () => { + const testCases: ReadonlyArray<{ + readonly name: string, + readonly nodes: TreeNode[], + readonly expectedValue: TreeNode | undefined, + }> = [ + { + name: 'should return undefined if no node is focused', + nodes: [], + expectedValue: undefined, + }, + (() => { + const unfocusedNode = getNodeWithFocusState(false); + const focusedNode = getNodeWithFocusState(true); + return { + name: 'should return the single focused node', + nodes: [focusedNode, unfocusedNode], + expectedValue: focusedNode, + }; + })(), + { + name: 'should return undefined if multiple nodes are focused', + nodes: [getNodeWithFocusState(true), getNodeWithFocusState(true)], + expectedValue: undefined, + }, + ]; + testCases.forEach(({ name, nodes, expectedValue }) => { + it(name, () => { + // arrange + const collection = new TreeNodeCollectionStub() + .withNodes(new QueryableNodesStub().withFlattenedNodes(nodes)); + const focusManager = new SingleNodeCollectionFocusManager(collection); + // act + const singleFocusedNode = focusManager.currentSingleFocusedNode; + // assert + expect(singleFocusedNode).to.equal(expectedValue); + }); + }); + }); + + describe('setSingleFocus', () => { + it('should set focus on given node and remove focus from all others', () => { + // arrange + const node1 = getNodeWithFocusState(true); + const node2 = getNodeWithFocusState(true); + const node3 = getNodeWithFocusState(false); + const collection = new TreeNodeCollectionStub() + .withNodes(new QueryableNodesStub().withFlattenedNodes([node1, node2, node3])); + // act + const focusManager = new SingleNodeCollectionFocusManager(collection); + focusManager.setSingleFocus(node3); + // assert + expect(node1.state.current.isFocused).toBeFalsy(); + expect(node2.state.current.isFocused).toBeFalsy(); + expect(node3.state.current.isFocused).toBeTruthy(); + }); + it('should set currentSingleFocusedNode as expected', () => { + // arrange + const nodeToFocus = getNodeWithFocusState(false); + const collection = new TreeNodeCollectionStub() + .withNodes(new QueryableNodesStub().withFlattenedNodes([ + getNodeWithFocusState(false), + getNodeWithFocusState(true), + nodeToFocus, + getNodeWithFocusState(false), + getNodeWithFocusState(true), + ])); + // act + const focusManager = new SingleNodeCollectionFocusManager(collection); + focusManager.setSingleFocus(nodeToFocus); + // assert + expect(focusManager.currentSingleFocusedNode).toEqual(nodeToFocus); + }); + }); +}); + +function getNodeWithFocusState(isFocused: boolean): TreeNodeStub { + return new TreeNodeStub() + .withState(new TreeNodeStateAccessStub().withCurrent( + new TreeNodeStateDescriptorStub().withFocusState(isFocused), + )); +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/TreeNodeNavigator.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/TreeNodeNavigator.spec.ts new file mode 100644 index 00000000..1d28d7ed --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/TreeNodeNavigator.spec.ts @@ -0,0 +1,108 @@ +import { expect } from 'vitest'; +import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub'; +import { HierarchyAccessStub } from '@tests/unit/shared/Stubs/HierarchyAccessStub'; +import { TreeNodeNavigator } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/TreeNodeNavigator'; + +describe('TreeNodeNavigator', () => { + describe('flattenedNodes', () => { + it('should flatten root nodes correctly', () => { + // arrange + const rootNode1 = new TreeNodeStub(); + const rootNode2 = new TreeNodeStub(); + const rootNode3 = new TreeNodeStub(); + // act + const navigator = new TreeNodeNavigator([rootNode1, rootNode2, rootNode3]); + // assert + expect(navigator.flattenedNodes).to.have.length(3); + expect(navigator.flattenedNodes).to.include.members([rootNode1, rootNode2, rootNode3]); + }); + + it('should flatten nested nodes correctly', () => { + // arrange + const nestedNode = new TreeNodeStub(); + const nestedNode2 = new TreeNodeStub(); + const rootNode = new TreeNodeStub() + .withHierarchy(new HierarchyAccessStub().withChildren([nestedNode, nestedNode2])); + // act + const navigator = new TreeNodeNavigator([rootNode]); + // assert + expect(navigator.flattenedNodes).to.have.length(3); + expect(navigator.flattenedNodes).to.include.members([nestedNode, nestedNode2, rootNode]); + }); + + it('should flatten deeply nested nodes correctly', () => { + // arrange + const deepNestedNode1 = new TreeNodeStub(); + const deepNestedNode2 = new TreeNodeStub(); + const nestedNode = new TreeNodeStub() + .withHierarchy(new HierarchyAccessStub().withChildren([deepNestedNode1, deepNestedNode2])); + const rootNode = new TreeNodeStub() + .withHierarchy(new HierarchyAccessStub().withChildren([nestedNode])); + // act + const navigator = new TreeNodeNavigator([rootNode]); + // assert + expect(navigator.flattenedNodes).to.have.length(4); + expect(navigator.flattenedNodes).to.include.members([ + rootNode, nestedNode, deepNestedNode1, deepNestedNode2, + ]); + }); + }); + + describe('rootNodes', () => { + it('should initialize with expected root nodes', () => { + // arrange + const nestedNode = new TreeNodeStub(); + const rootNode = new TreeNodeStub() + .withHierarchy(new HierarchyAccessStub().withChildren([nestedNode])); + // act + const navigator = new TreeNodeNavigator([rootNode]); + // assert + expect(navigator.rootNodes).to.have.length(1); + expect(navigator.flattenedNodes).to.include.members([rootNode]); + }); + }); + + describe('getNodeById', () => { + it('should find nested node by id', () => { + // arrange + const nodeId = 'nested-node-id'; + const expectedNode = new TreeNodeStub().withId(nodeId); + const rootNode = new TreeNodeStub() + .withHierarchy(new HierarchyAccessStub().withChildren([expectedNode])); + const navigator = new TreeNodeNavigator([rootNode]); + // act + const actualNode = navigator.getNodeById(nodeId); + // assert + expect(actualNode).to.equal(expectedNode); + }); + + it('should find root node by id', () => { + // arrange + const nodeId = 'root-node-id'; + const expectedRootNode = new TreeNodeStub().withId(nodeId); + const navigator = new TreeNodeNavigator([ + new TreeNodeStub(), + expectedRootNode, + new TreeNodeStub(), + ]); + // act + const actualNode = navigator.getNodeById(nodeId); + // assert + expect(actualNode).to.equal(expectedRootNode); + }); + + it('should throw an error if node cannot be found', () => { + // arrange + const absentNodeId = 'absent-node-id'; + const expectedError = `Node could not be found: ${absentNodeId}`; + const navigator = new TreeNodeNavigator([ + new TreeNodeStub(), + new TreeNodeStub(), + ]); + // act + const act = () => navigator.getNodeById(absentNodeId); + // assert + expect(act).to.throw(expectedError); + }); + }); +}); diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser.spec.ts new file mode 100644 index 00000000..9de524c7 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser.spec.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { parseTreeInput } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser'; +import { TreeInputNodeData } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData'; +import { TreeNodeManager } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNodeManager'; +import { TreeInputNodeDataStub } from '@tests/unit/shared/Stubs/TreeInputNodeDataStub'; +import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; + +describe('parseTreeInput', () => { + it('throws if input data is not an array', () => { + // arrange + const expectedError = 'input data must be an array'; + const invalidInput = 'invalid-input' as unknown as TreeInputNodeData[]; + // act + const act = () => parseTreeInput(invalidInput); + // assert + expect(act).to.throw(expectedError); + }); + + describe('throws if input data is absent', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedError = 'missing input'; + const invalidInput = absentValue; + // act + const act = () => parseTreeInput(invalidInput); + // assert + expect(act).to.throw(expectedError); + }); + }); + + it('returns an empty array if given an empty array', () => { + // arrange + const input = []; + // act + const nodes = parseTreeInput(input); + // assert + expect(nodes).to.have.lengthOf(0); + }); + + it(`creates ${TreeNodeManager.name} for each node`, () => { + // arrange + const input: TreeInputNodeData[] = [ + new TreeInputNodeDataStub(), + new TreeInputNodeDataStub(), + ]; + // act + const nodes = parseTreeInput(input); + // assert + expect(nodes).have.lengthOf(2); + expect(nodes[0]).to.be.instanceOf(TreeNodeManager); + expect(nodes[1]).to.be.instanceOf(TreeNodeManager); + }); + + it('converts flat input array to flat node array', () => { + // arrange + const inputNodes: TreeInputNodeData[] = [ + new TreeInputNodeDataStub().withId('1'), + new TreeInputNodeDataStub().withId('2'), + ]; + // act + const nodes = parseTreeInput(inputNodes); + // assert + expect(nodes).have.lengthOf(2); + expect(nodes[0].id).equal(inputNodes[0].id); + expect(nodes[0].hierarchy.children).to.have.lengthOf(0); + expect(nodes[0].hierarchy.parent).to.toBeUndefined(); + expect(nodes[1].id).equal(inputNodes[1].id); + expect(nodes[1].hierarchy.children).to.have.lengthOf(0); + expect(nodes[1].hierarchy.parent).to.toBeUndefined(); + }); + + it('correctly parses nested data with correct hierarchy', () => { + // arrange; + const grandChildData = new TreeInputNodeDataStub().withId('1-1-1'); + const childData = new TreeInputNodeDataStub().withId('1-1').withChildren([grandChildData]); + const parentNodeData = new TreeInputNodeDataStub().withId('1').withChildren([childData]); + const inputData: TreeInputNodeData[] = [parentNodeData]; + + // act + const nodes = parseTreeInput(inputData); + + // assert + expect(nodes).to.have.lengthOf(1); + expect(nodes[0].id).to.equal(parentNodeData.id); + expect(nodes[0].hierarchy.children).to.have.lengthOf(1); + + const childNode = nodes[0].hierarchy.children[0]; + expect(childNode.id).to.equal(childData.id); + expect(childNode.hierarchy.children).to.have.lengthOf(1); + expect(childNode.hierarchy.parent).to.equal(nodes[0]); + + const grandChildNode = childNode.hierarchy.children[0]; + expect(grandChildNode.id).to.equal(grandChildData.id); + expect(grandChildNode.hierarchy.children).to.have.lengthOf(0); + expect(grandChildNode.hierarchy.parent).to.equal(childNode); + }); +}); diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeInitializerAndUpdater.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeInitializerAndUpdater.spec.ts new file mode 100644 index 00000000..3829f4d2 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeInitializerAndUpdater.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { TreeNodeInitializerAndUpdater } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeInitializerAndUpdater'; +import { TreeNodeNavigator } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/TreeNodeNavigator'; +import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { createTreeNodeParserStub } from '@tests/unit/shared/Stubs/TreeNodeParserStub'; +import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub'; +import { TreeInputNodeDataStub } from '@tests/unit/shared/Stubs/TreeInputNodeDataStub'; +import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes'; + +describe('TreeNodeInitializerAndUpdater', () => { + describe('updateRootNodes', () => { + it('should throw an error if no data is provided', () => { + itEachAbsentCollectionValue((absentValue) => { + // arrange + const expectedError = 'missing data'; + const initializer = new TreeNodeInitializerAndUpdaterBuilder() + .build(); + // act + const act = () => initializer.updateRootNodes(absentValue); + // expect + expect(act).to.throw(expectedError); + }); + }); + + it('should update nodes when valid data is provided', () => { + // arrange + const expectedData = [new TreeNodeStub(), new TreeNodeStub()]; + const inputData = [new TreeInputNodeDataStub(), new TreeInputNodeDataStub()]; + const builder = new TreeNodeInitializerAndUpdaterBuilder(); + builder.parserStub.registerScenario({ + given: inputData, + result: expectedData, + }); + const initializer = builder.build(); + // act + initializer.updateRootNodes(inputData); + // assert + expect(initializer.nodes).to.be.instanceOf(TreeNodeNavigator); + expect(initializer.nodes.rootNodes).to.have.members(expectedData); + }); + + it('should notify when nodes are updated', () => { + // arrange + let notifiedNodes: QueryableNodes | undefined; + const inputData = [new TreeInputNodeDataStub(), new TreeInputNodeDataStub()]; + const expectedData = [new TreeNodeStub(), new TreeNodeStub()]; + const builder = new TreeNodeInitializerAndUpdaterBuilder(); + builder.parserStub.registerScenario({ + given: inputData, + result: expectedData, + }); + const initializer = builder.build(); + initializer.nodesUpdated.on((nodes) => { + notifiedNodes = nodes; + }); + // act + initializer.updateRootNodes(inputData); + // assert + expect(notifiedNodes).to.toBeTruthy(); + expect(initializer.nodes.rootNodes).to.have.members(expectedData); + }); + }); +}); + +class TreeNodeInitializerAndUpdaterBuilder { + public readonly parserStub = createTreeNodeParserStub(); + + public build() { + return new TreeNodeInitializerAndUpdater(this.parserStub.parseTreeInputStub); + } +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRootManager.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRootManager.ts new file mode 100644 index 00000000..e0f3a4cb --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRootManager.ts @@ -0,0 +1,53 @@ +import { SingleNodeCollectionFocusManager } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager'; +import { TreeNodeCollection } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeCollection'; +import { TreeNodeInitializerAndUpdater } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeInitializerAndUpdater'; +import { TreeRootManager } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRootManager'; +import { SingleNodeFocusManagerStub } from '@tests/unit/shared/Stubs/SingleNodeFocusManagerStub'; +import { TreeNodeCollectionStub } from '@tests/unit/shared/Stubs/TreeNodeCollectionStub'; + +describe('TreeRootManager', () => { + describe('collection', () => { + it(`defaults to ${TreeNodeInitializerAndUpdater.name}`, () => { + // arrange + const expectedCollectionType = TreeNodeInitializerAndUpdater; + const sut = new TreeRootManager(); + // act + const actualCollection = sut.collection; + // assert + expect(actualCollection).to.be.instanceOf(expectedCollectionType); + }); + it('set by constructor as expected', () => { + // arrange + const expectedCollection = new TreeNodeCollectionStub(); + const sut = new TreeRootManager(); + // act + const actualCollection = sut.collection; + // assert + expect(actualCollection).to.equal(expectedCollection); + }); + }); + describe('focus', () => { + it(`defaults to instance of ${SingleNodeCollectionFocusManager.name}`, () => { + // arrange + const expectedFocusType = SingleNodeCollectionFocusManager; + const sut = new TreeRootManager(); + // act + const actualFocusType = sut.focus; + // assert + expect(actualFocusType).to.be.instanceOf(expectedFocusType); + }); + it('creates with same collection it uses', () => { + // arrange + let usedCollection: TreeNodeCollection | undefined; + const factoryMock = (collection) => { + usedCollection = collection; + return new SingleNodeFocusManagerStub(); + }; + const sut = new TreeRootManager(new TreeNodeCollectionStub(), factoryMock); + // act + const expected = sut.collection; + // assert + expect(usedCollection).to.equal(expected); + }); + }); +}); diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes.spec.ts new file mode 100644 index 00000000..e559bf13 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { + ref, defineComponent, WatchSource, nextTick, +} from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot'; +import { InjectionKeys } from '@/presentation/injectionSymbols'; +import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub'; +import { QueryableNodesStub } from '@tests/unit/shared/Stubs/QueryableNodesStub'; +import { TreeNodeCollectionStub } from '@tests/unit/shared/Stubs/TreeNodeCollectionStub'; +import { TreeRootStub } from '@tests/unit/shared/Stubs/TreeRootStub'; +import { useCurrentTreeNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes'; + +describe('useCurrentTreeNodes', () => { + it('should set nodes on immediate invocation', () => { + // arrange + const expectedNodes = new QueryableNodesStub(); + const treeWatcher = ref(new TreeRootStub().withCollection( + new TreeNodeCollectionStub().withNodes(expectedNodes), + )); + // act + const { returnObject } = mountWrapperComponent(treeWatcher); + // assert + expect(returnObject.nodes.value).to.deep.equal(expectedNodes); + }); + + it('should update nodes when treeWatcher changes', async () => { + // arrange + const initialNodes = new QueryableNodesStub(); + const treeWatcher = ref( + new TreeRootStub().withCollection(new TreeNodeCollectionStub().withNodes(initialNodes)), + ); + const { returnObject } = mountWrapperComponent(treeWatcher); + const newExpectedNodes = new QueryableNodesStub(); + // act + treeWatcher.value = new TreeRootStub().withCollection( + new TreeNodeCollectionStub().withNodes(newExpectedNodes), + ); + await nextTick(); + // assert + expect(returnObject.nodes.value).to.deep.equal(newExpectedNodes); + }); + + it('should update nodes when tree collection nodesUpdated event is triggered', async () => { + // arrange + const initialNodes = new QueryableNodesStub(); + const treeCollectionStub = new TreeNodeCollectionStub().withNodes(initialNodes); + const treeWatcher = ref(new TreeRootStub().withCollection(treeCollectionStub)); + + const { returnObject } = mountWrapperComponent(treeWatcher); + + const newExpectedNodes = new QueryableNodesStub(); + // act + treeCollectionStub.triggerNodesUpdatedEvent(newExpectedNodes); + await nextTick(); + // assert + expect(returnObject.nodes.value).to.deep.equal(newExpectedNodes); + }); +}); + +function mountWrapperComponent(treeWatcher: WatchSource) { + let returnObject: ReturnType; + const wrapper = shallowMount( + defineComponent({ + setup() { + returnObject = useCurrentTreeNodes(treeWatcher); + }, + template: '
', + }), + { + provide: { + [InjectionKeys.useAutoUnsubscribedEvents as symbol]: + () => new UseAutoUnsubscribedEventsStub().get(), + }, + }, + ); + return { + wrapper, + returnObject, + }; +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/ScriptNodeParser.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter.spec.ts similarity index 83% rename from tests/unit/presentation/components/Scripts/View/Tree/ScriptNodeParser.spec.ts rename to tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter.spec.ts index 1dbe24ed..33feab55 100644 --- a/tests/unit/presentation/components/Scripts/View/Tree/ScriptNodeParser.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter.spec.ts @@ -1,16 +1,17 @@ import { describe, it, expect } from 'vitest'; -import { - getScriptNodeId, getScriptId, getCategoryNodeId, getCategoryId, parseSingleCategory, - parseAllCategories, -} from '@/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser'; -import { INodeContent, NodeType } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent'; import { IScript } from '@/domain/IScript'; import { ICategory } from '@/domain/ICategory'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; +import { + getCategoryId, getCategoryNodeId, getScriptId, + getScriptNodeId, parseAllCategories, parseSingleCategory, +} from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter'; +import { NodeType } from '@/application/Parser/NodeValidation/NodeType'; +import { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; -describe('ScriptNodeParser', () => { +describe('CategoryNodeMetadataConverter', () => { it('can convert script id and back', () => { // arrange const script = new ScriptStub('test'); @@ -30,6 +31,16 @@ describe('ScriptNodeParser', () => { expect(scriptId).to.equal(category.id); }); describe('parseSingleCategory', () => { + it('throws error when parent category does not exist', () => { + // arrange + const categoryId = 33; + const expectedError = `Category with id ${categoryId} does not exist`; + const collection = new CategoryCollectionStub(); + // act + const act = () => parseSingleCategory(categoryId, collection); + // assert + expect(act).to.throw(expectedError); + }); it('can parse when category has sub categories', () => { // arrange const categoryId = 31; @@ -86,7 +97,7 @@ function isReversible(category: ICategory): boolean { return category.subCategories.every((c) => isReversible(c)); } -function expectSameCategory(node: INodeContent, category: ICategory): void { +function expectSameCategory(node: NodeMetadata, category: ICategory): void { expect(node.type).to.equal(NodeType.Category, getErrorMessage('type')); expect(node.id).to.equal(getCategoryNodeId(category), getErrorMessage('id')); expect(node.docs).to.equal(category.docs, getErrorMessage('docs')); @@ -106,7 +117,7 @@ function expectSameCategory(node: INodeContent, category: ICategory): void { } } -function expectSameScript(node: INodeContent, script: IScript): void { +function expectSameScript(node: NodeMetadata, script: IScript): void { expect(node.type).to.equal(NodeType.Script, getErrorMessage('type')); expect(node.id).to.equal(getScriptNodeId(script), getErrorMessage('id')); expect(node.docs).to.equal(script.docs, getErrorMessage('docs')); diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/TreeNodeMetadataConverter.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/TreeNodeMetadataConverter.spec.ts new file mode 100644 index 00000000..aa1e1eae --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/TreeNodeMetadataConverter.spec.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { getNodeMetadata, convertToNodeInput } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/TreeNodeMetadataConverter'; +import { NodeMetadataStub } from '@tests/unit/shared/Stubs/NodeMetadataStub'; +import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub'; +import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { ReadOnlyTreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode'; +import { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; + +describe('TreeNodeMetadataConverter', () => { + describe('getNodeMetadata', () => { + it('retrieves node metadata as expected', () => { + // arrange + const expectedMetadata = new NodeMetadataStub(); + const treeNode = new TreeNodeStub() + .withMetadata(expectedMetadata); + // act + const actual = getNodeMetadata(treeNode); + // assert + expect(actual).to.equal(expectedMetadata); + }); + describe('throws when tree node is absent', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedError = 'missing tree node'; + const absentTreeNode = absentValue as ReadOnlyTreeNode; + // act + const act = () => getNodeMetadata(absentTreeNode); + // assert + expect(act).to.throw(expectedError); + }); + }); + describe('throws when metadata is absent', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedError = 'Provided node does not contain the expected metadata.'; + const absentMetadata = absentValue as NodeMetadata; + const treeNode = new TreeNodeStub() + .withMetadata(absentMetadata); + // act + const act = () => getNodeMetadata(treeNode); + // assert + expect(act).to.throw(expectedError); + }); + }); + }); + describe('convertToNodeInput', () => { + it('sets metadata as tree node data', () => { + // arrange + const expectedMetadata = new NodeMetadataStub(); + // act + const actual = convertToNodeInput(expectedMetadata); + // assert + expect(actual.data).to.equal(expectedMetadata); + }); + describe('throws when metadata is missing', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedError = 'missing metadata'; + const absentMetadata = absentValue as NodeMetadata; + // act + const act = () => convertToNodeInput(absentMetadata); + // assert + expect(act).to.throw(expectedError); + }); + }); + describe('children conversion', () => { + it('correctly converts metadata without children', () => { + // arrange + const metadataWithoutChildren = new NodeMetadataStub(); + // act + const actual = convertToNodeInput(metadataWithoutChildren); + // assert + expect(actual.children).to.have.lengthOf(0); + }); + + it('converts children nodes', () => { + // arrange + const expectedChildren = [new NodeMetadataStub(), new NodeMetadataStub()]; + const expected = new NodeMetadataStub() + .withChildren(expectedChildren); + // act + const actual = convertToNodeInput(expected); + // assert + expect(actual.children).to.have.lengthOf(expectedChildren.length); + expect(actual.children[0].data).to.equal(expectedChildren[0]); + expect(actual.children[1].data).to.equal(expectedChildren[1]); + }); + + it('converts nested children nodes recursively', () => { + // arrange + const childLevel2Instance1 = new NodeMetadataStub().withId('L2-1'); + const childLevel2Instance2 = new NodeMetadataStub().withId('L2-2'); + const childLevel1 = new NodeMetadataStub().withChildren( + [childLevel2Instance1, childLevel2Instance2], + ); + const rootNode = new NodeMetadataStub().withChildren([childLevel1]).withId('root'); + // act + const actual = convertToNodeInput(rootNode); + // assert + expect(actual.children).to.have.lengthOf(1); + expect(actual.children[0].data).to.equal(childLevel1); + expect(actual.children[0].children[0].data).to.equal(childLevel2Instance1); + expect(actual.children[0].children[1].data).to.equal(childLevel2Instance2); + }); + }); + }); +}); diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater.spec.ts new file mode 100644 index 00000000..81585787 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater.spec.ts @@ -0,0 +1,194 @@ +import { shallowMount } from '@vue/test-utils'; +import { describe, it, expect } from 'vitest'; +import { TreeNodeStateChangedEmittedEvent } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeNodeStateChangedEmittedEvent'; +import { useCollectionSelectionStateUpdater } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater'; +import { InjectionKeys } from '@/presentation/injectionSymbols'; +import { HierarchyAccessStub } from '@tests/unit/shared/Stubs/HierarchyAccessStub'; +import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub'; +import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub'; +import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub'; +import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub'; +import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState'; +import { NodeStateChangedEventStub } from '@tests/unit/shared/Stubs/NodeStateChangedEventStub'; +import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub'; +import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub'; + +describe('useCollectionSelectionStateUpdater', () => { + describe('updateNodeSelection', () => { + describe('when node is a branch node', () => { + it('does nothing', () => { + // arrange + const { returnObject, useStateStub } = mountWrapperComponent(); + const mockEvent: TreeNodeStateChangedEmittedEvent = { + node: createTreeNodeStub({ + isBranch: true, + currentState: TreeNodeCheckState.Checked, + }), + change: new NodeStateChangedEventStub().withCheckStateChange({ + oldState: TreeNodeCheckState.Checked, + newState: TreeNodeCheckState.Unchecked, + }), + }; + // act + returnObject.updateNodeSelection(mockEvent); + // assert + const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState'); + expect(modifyCall).toBeUndefined(); + }); + }); + describe('when old and new check states are the same', () => { + it('does nothing', () => { + // arrange + const { returnObject, useStateStub } = mountWrapperComponent(); + const mockEvent: TreeNodeStateChangedEmittedEvent = { + node: createTreeNodeStub({ + isBranch: false, + currentState: TreeNodeCheckState.Checked, + }), + change: new NodeStateChangedEventStub().withCheckStateChange({ + oldState: TreeNodeCheckState.Checked, + newState: TreeNodeCheckState.Checked, + }), + }; + // act + returnObject.updateNodeSelection(mockEvent); + // assert + const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState'); + expect(modifyCall).toBeUndefined(); + }); + }); + describe('when checkState is checked', () => { + it('adds to selection if not already selected', () => { + // arrange + const { returnObject, useStateStub } = mountWrapperComponent(); + const selectionStub = new UserSelectionStub([]); + selectionStub.isSelected = () => false; + useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub)); + const mockEvent: TreeNodeStateChangedEmittedEvent = { + node: new TreeNodeStub() + .withHierarchy(new HierarchyAccessStub().withIsBranchNode(false)) + .withState(new TreeNodeStateAccessStub().withCurrent( + new TreeNodeStateDescriptorStub().withCheckState( + TreeNodeCheckState.Checked, + ), + )), + change: new NodeStateChangedEventStub().withCheckStateChange({ + oldState: TreeNodeCheckState.Unchecked, + newState: TreeNodeCheckState.Checked, + }), + }; + // act + returnObject.updateNodeSelection(mockEvent); + // assert + const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState'); + expect(modifyCall).toBeDefined(); + const addSelectedScriptCall = selectionStub.callHistory.find((call) => call.methodName === 'addSelectedScript'); + expect(addSelectedScriptCall).toBeDefined(); + }); + it('does nothing if already selected', () => { + // arrange + const { returnObject, useStateStub } = mountWrapperComponent(); + const selectionStub = new UserSelectionStub([]); + selectionStub.isSelected = () => true; + useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub)); + const mockEvent: TreeNodeStateChangedEmittedEvent = { + node: createTreeNodeStub({ + isBranch: false, + currentState: TreeNodeCheckState.Checked, + }), + change: new NodeStateChangedEventStub().withCheckStateChange({ + oldState: TreeNodeCheckState.Unchecked, + newState: TreeNodeCheckState.Checked, + }), + }; + // act + returnObject.updateNodeSelection(mockEvent); + // assert + const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState'); + expect(modifyCall).toBeUndefined(); + }); + }); + describe('when checkState is unchecked', () => { + it('removes from selection if already selected', () => { + // arrange + const { returnObject, useStateStub } = mountWrapperComponent(); + const selectionStub = new UserSelectionStub([]); + selectionStub.isSelected = () => true; + useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub)); + const mockEvent: TreeNodeStateChangedEmittedEvent = { + node: createTreeNodeStub({ + isBranch: false, + currentState: TreeNodeCheckState.Unchecked, + }), + change: new NodeStateChangedEventStub().withCheckStateChange({ + oldState: TreeNodeCheckState.Checked, + newState: TreeNodeCheckState.Unchecked, + }), + }; + // act + returnObject.updateNodeSelection(mockEvent); + // assert + const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState'); + expect(modifyCall).toBeDefined(); + const removeSelectedScriptCall = selectionStub.callHistory.find((call) => call.methodName === 'removeSelectedScript'); + expect(removeSelectedScriptCall).toBeDefined(); + }); + it('does nothing if not already selected', () => { + // arrange + const { returnObject, useStateStub } = mountWrapperComponent(); + const selectionStub = new UserSelectionStub([]); + selectionStub.isSelected = () => false; + useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub)); + const mockEvent: TreeNodeStateChangedEmittedEvent = { + node: createTreeNodeStub({ + isBranch: false, + currentState: TreeNodeCheckState.Unchecked, + }), + change: new NodeStateChangedEventStub().withCheckStateChange({ + oldState: TreeNodeCheckState.Checked, + newState: TreeNodeCheckState.Unchecked, + }), + }; + // act + returnObject.updateNodeSelection(mockEvent); + // assert + const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState'); + expect(modifyCall).toBeUndefined(); + }); + }); + }); +}); + +function mountWrapperComponent() { + const useStateStub = new UseCollectionStateStub(); + let returnObject: ReturnType; + + shallowMount({ + setup() { + returnObject = useCollectionSelectionStateUpdater(); + }, + template: '
', + }, { + provide: { + [InjectionKeys.useCollectionState as symbol]: () => useStateStub.get(), + }, + }); + + return { + returnObject, + useStateStub, + }; +} + +function createTreeNodeStub(scenario: { + readonly isBranch: boolean, + readonly currentState: TreeNodeCheckState, +}) { + return new TreeNodeStub() + .withHierarchy(new HierarchyAccessStub().withIsBranchNode(scenario.isBranch)) + .withState(new TreeNodeStateAccessStub().withCurrent( + new TreeNodeStateDescriptorStub().withCheckState( + scenario.currentState, + ), + )); +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseSelectedScriptNodeIds.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseSelectedScriptNodeIds.spec.ts new file mode 100644 index 00000000..6d42ab5e --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseSelectedScriptNodeIds.spec.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import { useSelectedScriptNodeIds } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseSelectedScriptNodeIds'; +import { InjectionKeys } from '@/presentation/injectionSymbols'; +import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub'; +import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub'; +import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub'; +import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub'; +import { getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter'; +import { IScript } from '@/domain/IScript'; + +describe('useSelectedScriptNodeIds', () => { + it('returns empty array when no scripts are selected', () => { + // arrange + const { useStateStub, returnObject } = mountWrapperComponent(); + useStateStub.withState(new CategoryCollectionStateStub().withSelectedScripts([])); + // act + const actualIds = returnObject.selectedScriptNodeIds.value; + // assert + expect(actualIds).to.have.lengthOf(0); + }); + + it('returns correct node IDs for selected scripts', () => { + // arrange + const selectedScripts = [ + new SelectedScriptStub('id-1'), + new SelectedScriptStub('id-2'), + ]; + const parsedNodeIds = new Map([ + [selectedScripts[0].script, 'expected-id-1'], + [selectedScripts[1].script, 'expected-id-1'], + ]); + const { useStateStub, returnObject } = mountWrapperComponent({ + scriptNodeIdParser: (script) => parsedNodeIds.get(script), + }); + useStateStub.triggerOnStateChange({ + newState: new CategoryCollectionStateStub().withSelectedScripts(selectedScripts), + immediateOnly: true, + }); + // act + const actualIds = returnObject.selectedScriptNodeIds.value; + // assert + const expectedNodeIds = [...parsedNodeIds.values()]; + expect(actualIds).to.have.lengthOf(expectedNodeIds.length); + expect(actualIds).to.include.members(expectedNodeIds); + }); +}); + +function mountWrapperComponent(scenario?: { + readonly scriptNodeIdParser?: typeof getScriptNodeId, +}) { + const useStateStub = new UseCollectionStateStub(); + const nodeIdParser: typeof getScriptNodeId = scenario?.scriptNodeIdParser + ?? ((script) => script.id); + let returnObject: ReturnType; + + shallowMount({ + setup() { + returnObject = useSelectedScriptNodeIds(nodeIdParser); + }, + template: '
', + }, { + provide: { + [InjectionKeys.useCollectionState as symbol]: + () => useStateStub.get(), + [InjectionKeys.useAutoUnsubscribedEvents as symbol]: + () => new UseAutoUnsubscribedEventsStub().get(), + }, + }); + + return { + returnObject, + useStateStub, + }; +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewNodeInput.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewNodeInput.spec.ts new file mode 100644 index 00000000..34920a92 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewNodeInput.spec.ts @@ -0,0 +1,194 @@ +import { shallowMount } from '@vue/test-utils'; +import { describe, it, expect } from 'vitest'; +import { WatchSource, ref, nextTick } from 'vue'; +import { CategoryNodeParser, useTreeViewNodeInput } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewNodeInput'; +import { InjectionKeys } from '@/presentation/injectionSymbols'; +import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub'; +import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub'; +import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; +import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; +import { ICategoryCollection } from '@/domain/ICategoryCollection'; +import { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; +import { NodeMetadataStub } from '@tests/unit/shared/Stubs/NodeMetadataStub'; +import { convertToNodeInput } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/TreeNodeMetadataConverter'; +import { TreeInputNodeDataStub as TreeInputNodeData, TreeInputNodeDataStub } from '@tests/unit/shared/Stubs/TreeInputNodeDataStub'; + +describe('useTreeViewNodeInput', () => { + describe('when given categoryId', () => { + it('sets input nodes correctly', async () => { + // arrange + const testCategoryId = ref(); + const { + useStateStub, returnObject, parserMock, converterMock, + } = mountWrapperComponent( + () => testCategoryId.value, + ); + const expectedCategoryId = 123; + const expectedCategoryCollection = new CategoryCollectionStub().withAction( + new CategoryStub(expectedCategoryId), + ); + const expectedMetadata = [new NodeMetadataStub(), new NodeMetadataStub()]; + parserMock.setupParseSingleScenario({ + givenId: expectedCategoryId, + givenCollection: expectedCategoryCollection, + parseResult: expectedMetadata, + }); + const expectedNodeInputData = [new TreeInputNodeDataStub(), new TreeInputNodeDataStub()]; + expectedMetadata.forEach((metadata, index) => { + converterMock.setupConversionScenario({ + givenMetadata: metadata, + convertedNode: expectedNodeInputData[index], + }); + }); + useStateStub.withState( + new CategoryCollectionStateStub().withCollection(expectedCategoryCollection), + ); + // act + const { treeViewInputNodes } = returnObject; + testCategoryId.value = expectedCategoryId; + await nextTick(); + // assert + const actualInputNodes = treeViewInputNodes.value; + expect(actualInputNodes).have.lengthOf(expectedNodeInputData.length); + expect(actualInputNodes).include.members(expectedNodeInputData); + }); + }); + + describe('when not given a categoryId', () => { + it('sets input nodes correctly', () => { + // arrange + const testCategoryId = ref(); + const { + useStateStub, returnObject, parserMock, converterMock, + } = mountWrapperComponent( + () => testCategoryId.value, + ); + const expectedCategoryCollection = new CategoryCollectionStub().withAction( + new CategoryStub(123), + ); + const expectedMetadata = [new NodeMetadataStub(), new NodeMetadataStub()]; + parserMock.setupParseAllScenario({ + givenCollection: expectedCategoryCollection, + parseResult: expectedMetadata, + }); + const expectedNodeInputData = [new TreeInputNodeDataStub(), new TreeInputNodeDataStub()]; + expectedMetadata.forEach((metadata, index) => { + converterMock.setupConversionScenario({ + givenMetadata: metadata, + convertedNode: expectedNodeInputData[index], + }); + }); + useStateStub.withState( + new CategoryCollectionStateStub().withCollection(expectedCategoryCollection), + ); + // act + const { treeViewInputNodes } = returnObject; + testCategoryId.value = undefined; + // assert + const actualInputNodes = treeViewInputNodes.value; + expect(actualInputNodes).have.lengthOf(expectedNodeInputData.length); + expect(actualInputNodes).include.members(expectedNodeInputData); + }); + }); +}); + +function mountWrapperComponent(categoryIdWatcher: WatchSource) { + const useStateStub = new UseCollectionStateStub(); + const parserMock = mockCategoryNodeParser(); + const converterMock = mockConverter(); + let returnObject: ReturnType; + + shallowMount({ + setup() { + returnObject = useTreeViewNodeInput(categoryIdWatcher, parserMock.mock, converterMock.mock); + }, + template: '
', + }, { + provide: { + [InjectionKeys.useCollectionState as symbol]: () => useStateStub.get(), + }, + }); + + return { + returnObject, + useStateStub, + parserMock, + converterMock, + }; +} + +interface ConversionScenario { + readonly givenMetadata: NodeMetadata; + readonly convertedNode: TreeInputNodeData; +} + +function mockConverter() { + const scenarios = new Array(); + + const mock: typeof convertToNodeInput = (metadata) => { + const scenario = scenarios.find((s) => s.givenMetadata === metadata); + if (scenario) { + return scenario.convertedNode; + } + return new TreeInputNodeData(); + }; + + function setupConversionScenario(scenario: ConversionScenario) { + scenarios.push(scenario); + } + + return { + mock, + setupConversionScenario, + }; +} + +interface ParseSingleScenario { + readonly givenId: number; + readonly givenCollection: ICategoryCollection; + readonly parseResult: NodeMetadata[]; +} + +interface ParseAllScenario { + readonly givenCollection: ICategoryCollection; + readonly parseResult: NodeMetadata[]; +} + +function mockCategoryNodeParser() { + const parseSingleScenarios = new Array(); + + const parseAllScenarios = new Array(); + + const mock: CategoryNodeParser = { + parseSingle: (id, collection) => { + const scenario = parseSingleScenarios + .find((s) => s.givenId === id && s.givenCollection === collection); + if (scenario) { + return scenario.parseResult; + } + return []; + }, + parseAll: (collection) => { + const scenario = parseAllScenarios + .find((s) => s.givenCollection === collection); + if (scenario) { + return scenario.parseResult; + } + return []; + }, + }; + + function setupParseSingleScenario(scenario: ParseSingleScenario) { + parseSingleScenarios.push(scenario); + } + + function setupParseAllScenario(scenario: ParseAllScenario) { + parseAllScenarios.push(scenario); + } + + return { + mock, + setupParseAllScenario, + setupParseSingleScenario, + }; +} diff --git a/tests/unit/shared/Stubs/CategoryCollectionStateStub.ts b/tests/unit/shared/Stubs/CategoryCollectionStateStub.ts index 2a9d5cf2..9fc63454 100644 --- a/tests/unit/shared/Stubs/CategoryCollectionStateStub.ts +++ b/tests/unit/shared/Stubs/CategoryCollectionStateStub.ts @@ -6,6 +6,7 @@ import { SelectedScript } from '@/application/Context/State/Selection/SelectedSc import { IScript } from '@/domain/IScript'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ICategoryCollection } from '@/domain/ICategoryCollection'; +import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection'; import { CategoryCollectionStub } from './CategoryCollectionStub'; import { UserSelectionStub } from './UserSelectionStub'; import { UserFilterStub } from './UserFilterStub'; @@ -13,42 +14,53 @@ import { ApplicationCodeStub } from './ApplicationCodeStub'; import { CategoryStub } from './CategoryStub'; export class CategoryCollectionStateStub implements ICategoryCollectionState { - private collectionStub = new CategoryCollectionStub(); - public readonly code: IApplicationCode = new ApplicationCodeStub(); public filter: IUserFilter = new UserFilterStub(); public get os(): OperatingSystem { - return this.collectionStub.os; + return this.collection.os; } - public get collection(): ICategoryCollection { - return this.collectionStub; - } + public collection: ICategoryCollection = new CategoryCollectionStub(); - public readonly selection: UserSelectionStub; + public selection: IUserSelection = new UserSelectionStub([]); constructor(readonly allScripts: IScript[] = [new ScriptStub('script-id')]) { this.selection = new UserSelectionStub(allScripts); - this.collectionStub = new CategoryCollectionStub() + this.collection = new CategoryCollectionStub() .withOs(this.os) .withTotalScripts(this.allScripts.length) .withAction(new CategoryStub(0).withScripts(...allScripts)); } - public withOs(os: OperatingSystem) { - this.collectionStub = this.collectionStub.withOs(os); + public withCollection(collection: ICategoryCollection): this { + this.collection = collection; return this; } - public withFilter(filter: IUserFilter) { + public withOs(os: OperatingSystem): this { + if (this.collection instanceof CategoryCollectionStub) { + this.collection = this.collection.withOs(os); + } else { + this.collection = new CategoryCollectionStub().withOs(os); + } + return this; + } + + public withFilter(filter: IUserFilter): this { this.filter = filter; return this; } - public withSelectedScripts(initialScripts: readonly SelectedScript[]) { - this.selection.withSelectedScripts(initialScripts); + public withSelectedScripts(initialScripts: readonly SelectedScript[]): this { + return this.withSelection( + new UserSelectionStub([]).withSelectedScripts(initialScripts), + ); + } + + public withSelection(selection: IUserSelection) { + this.selection = selection; return this; } } diff --git a/tests/unit/shared/Stubs/HierarchyAccessStub.ts b/tests/unit/shared/Stubs/HierarchyAccessStub.ts new file mode 100644 index 00000000..3a71d4b9 --- /dev/null +++ b/tests/unit/shared/Stubs/HierarchyAccessStub.ts @@ -0,0 +1,42 @@ +import { HierarchyAccess } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/HierarchyAccess'; +import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode'; + +export class HierarchyAccessStub implements HierarchyAccess { + public parent: TreeNode = undefined; + + public children: readonly TreeNode[] = []; + + public depthInTree = 0; + + public isLeafNode = true; + + public isBranchNode = false; + + public setParent(parent: TreeNode): void { + this.parent = parent; + } + + public setChildren(children: readonly TreeNode[]): void { + this.children = children; + } + + public withParent(parent: TreeNode): this { + this.parent = parent; + return this; + } + + public withDepthInTree(depthInTree: number): this { + this.depthInTree = depthInTree; + return this; + } + + public withChildren(children: readonly TreeNode[]): this { + this.setChildren(children); + return this; + } + + public withIsBranchNode(value: boolean): this { + this.isBranchNode = value; + return this; + } +} diff --git a/tests/unit/shared/Stubs/NodeMetadataStub.ts b/tests/unit/shared/Stubs/NodeMetadataStub.ts new file mode 100644 index 00000000..612144a0 --- /dev/null +++ b/tests/unit/shared/Stubs/NodeMetadataStub.ts @@ -0,0 +1,25 @@ +import { NodeMetadata, NodeType } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; + +export class NodeMetadataStub implements NodeMetadata { + public id = 'stub-id'; + + public readonly text: string = 'stub-text'; + + public readonly isReversible: boolean = false; + + public readonly docs: readonly string[] = []; + + public children?: readonly NodeMetadata[] = []; + + public readonly type: NodeType = NodeType.Category; + + public withChildren(children: readonly NodeMetadata[]): this { + this.children = children; + return this; + } + + public withId(id: string): this { + this.id = id; + return this; + } +} diff --git a/tests/unit/shared/Stubs/NodeStateChangedEventStub.ts b/tests/unit/shared/Stubs/NodeStateChangedEventStub.ts new file mode 100644 index 00000000..69d30fb2 --- /dev/null +++ b/tests/unit/shared/Stubs/NodeStateChangedEventStub.ts @@ -0,0 +1,33 @@ +import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState'; +import { NodeStateChangedEvent } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess'; +import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor'; +import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub'; + +export class NodeStateChangedEventStub implements NodeStateChangedEvent { + public oldState: TreeNodeStateDescriptor = new TreeNodeStateDescriptorStub(); + + public newState: TreeNodeStateDescriptor = new TreeNodeStateDescriptorStub(); + + public withNewState(newState: TreeNodeStateDescriptor): this { + this.newState = newState; + return this; + } + + public withOldState(oldState: TreeNodeStateDescriptor): this { + this.oldState = oldState; + return this; + } + + public withCheckStateChange(change: { + readonly oldState: TreeNodeCheckState, + readonly newState: TreeNodeCheckState, + }) { + return this + .withOldState( + new TreeNodeStateDescriptorStub().withCheckState(change.oldState), + ) + .withNewState( + new TreeNodeStateDescriptorStub().withCheckState(change.newState), + ); + } +} diff --git a/tests/unit/shared/Stubs/QueryableNodesStub.ts b/tests/unit/shared/Stubs/QueryableNodesStub.ts new file mode 100644 index 00000000..ab9b2b51 --- /dev/null +++ b/tests/unit/shared/Stubs/QueryableNodesStub.ts @@ -0,0 +1,22 @@ +import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode'; +import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes'; + +export class QueryableNodesStub implements QueryableNodes { + public rootNodes: readonly TreeNode[]; + + public flattenedNodes: readonly TreeNode[]; + + public getNodeById(): TreeNode { + throw new Error('Method not implemented.'); + } + + public withRootNodes(rootNodes: readonly TreeNode[]): this { + this.rootNodes = rootNodes; + return this; + } + + public withFlattenedNodes(flattenedNodes: readonly TreeNode[]): this { + this.flattenedNodes = flattenedNodes; + return this; + } +} diff --git a/tests/unit/shared/Stubs/SingleNodeFocusManagerStub.ts b/tests/unit/shared/Stubs/SingleNodeFocusManagerStub.ts new file mode 100644 index 00000000..2e567882 --- /dev/null +++ b/tests/unit/shared/Stubs/SingleNodeFocusManagerStub.ts @@ -0,0 +1,9 @@ +import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode'; +import { SingleNodeFocusManager } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeFocusManager'; +import { TreeNodeStub } from './TreeNodeStub'; + +export class SingleNodeFocusManagerStub implements SingleNodeFocusManager { + public currentSingleFocusedNode: TreeNode = new TreeNodeStub(); + + setSingleFocus(): void { /* NOOP */ } +} diff --git a/tests/unit/shared/Stubs/TreeInputNodeDataStub.ts b/tests/unit/shared/Stubs/TreeInputNodeDataStub.ts new file mode 100644 index 00000000..03397f11 --- /dev/null +++ b/tests/unit/shared/Stubs/TreeInputNodeDataStub.ts @@ -0,0 +1,26 @@ +import { TreeInputNodeData } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData'; + +export class TreeInputNodeDataStub implements TreeInputNodeData { + public id = 'stub-id'; + + public children?: readonly TreeInputNodeData[]; + + public parent?: TreeInputNodeData; + + public data?: object; + + public withData(data: object): this { + this.data = data; + return this; + } + + public withId(id: string): this { + this.id = id; + return this; + } + + public withChildren(children: readonly TreeInputNodeData[]): this { + this.children = children; + return this; + } +} diff --git a/tests/unit/shared/Stubs/TreeNodeCollectionStub.ts b/tests/unit/shared/Stubs/TreeNodeCollectionStub.ts new file mode 100644 index 00000000..a6f21dfc --- /dev/null +++ b/tests/unit/shared/Stubs/TreeNodeCollectionStub.ts @@ -0,0 +1,23 @@ +import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes'; +import { TreeNodeCollection } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeCollection'; +import { EventSourceStub } from './EventSourceStub'; + +export class TreeNodeCollectionStub implements TreeNodeCollection { + public nodes: QueryableNodes; + + public nodesUpdated = new EventSourceStub(); + + public updateRootNodes(): void { + throw new Error('Method not implemented.'); + } + + public withNodes(nodes: QueryableNodes): this { + this.nodes = nodes; + return this; + } + + public triggerNodesUpdatedEvent(nodes: QueryableNodes): this { + this.nodesUpdated.notify(nodes); + return this; + } +} diff --git a/tests/unit/shared/Stubs/TreeNodeParserStub.ts b/tests/unit/shared/Stubs/TreeNodeParserStub.ts new file mode 100644 index 00000000..f4b6b169 --- /dev/null +++ b/tests/unit/shared/Stubs/TreeNodeParserStub.ts @@ -0,0 +1,29 @@ +import { TreeInputNodeData } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData'; +import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode'; +import { parseTreeInput } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser'; +import { TreeNodeStub } from './TreeNodeStub'; + +interface StubScenario { + readonly given: readonly TreeInputNodeData[], + readonly result: TreeNode[], +} + +export function createTreeNodeParserStub() { + const scenarios = new Array(); + function registerScenario(scenario: StubScenario) { + scenarios.push(scenario); + } + const parseTreeInputStub: typeof parseTreeInput = ( + data: readonly TreeInputNodeData[], + ): TreeNode[] => { + const result = scenarios.find((scenario) => scenario.given === data); + if (result !== undefined) { + return result.result; + } + return data.map(() => new TreeNodeStub()); + }; + return { + registerScenario, + parseTreeInputStub, + }; +} diff --git a/tests/unit/shared/Stubs/TreeNodeStateAccessStub.ts b/tests/unit/shared/Stubs/TreeNodeStateAccessStub.ts new file mode 100644 index 00000000..d4e317f7 --- /dev/null +++ b/tests/unit/shared/Stubs/TreeNodeStateAccessStub.ts @@ -0,0 +1,45 @@ +import { NodeStateChangedEvent, TreeNodeStateAccess, TreeNodeStateTransaction } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess'; +import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor'; +import { TreeNodeStateDescriptorStub } from './TreeNodeStateDescriptorStub'; +import { EventSourceStub } from './EventSourceStub'; +import { TreeNodeStateTransactionStub } from './TreeNodeStateTransactionStub'; + +export class TreeNodeStateAccessStub implements TreeNodeStateAccess { + public current: TreeNodeStateDescriptor = new TreeNodeStateDescriptorStub(); + + public changed: EventSourceStub = new EventSourceStub(); + + public toggleCheck(): void { + throw new Error('Method not implemented.'); + } + + public toggleExpand(): void { + throw new Error('Method not implemented.'); + } + + public beginTransaction(): TreeNodeStateTransaction { + return new TreeNodeStateTransactionStub(); + } + + public commitTransaction(transaction: TreeNodeStateTransaction): void { + const oldState = this.current; + const newState = { + ...oldState, + ...transaction.updatedState, + }; + this.current = newState; + this.changed.notify({ + oldState, + newState, + }); + } + + public triggerStateChangedEvent(event: NodeStateChangedEvent) { + this.changed.notify(event); + } + + public withCurrent(state: TreeNodeStateDescriptor): this { + this.current = state; + return this; + } +} diff --git a/tests/unit/shared/Stubs/TreeNodeStateDescriptorStub.ts b/tests/unit/shared/Stubs/TreeNodeStateDescriptorStub.ts new file mode 100644 index 00000000..73048f92 --- /dev/null +++ b/tests/unit/shared/Stubs/TreeNodeStateDescriptorStub.ts @@ -0,0 +1,24 @@ +import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState'; +import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor'; + +export class TreeNodeStateDescriptorStub implements TreeNodeStateDescriptor { + public checkState: TreeNodeCheckState = TreeNodeCheckState.Checked; + + public isExpanded = false; + + public isVisible = false; + + public isMatched = false; + + public isFocused = false; + + public withFocusState(isFocused: boolean): this { + this.isFocused = isFocused; + return this; + } + + public withCheckState(checkState: TreeNodeCheckState): this { + this.checkState = checkState; + return this; + } +} diff --git a/tests/unit/shared/Stubs/TreeNodeStateTransactionStub.ts b/tests/unit/shared/Stubs/TreeNodeStateTransactionStub.ts new file mode 100644 index 00000000..e1e75c1c --- /dev/null +++ b/tests/unit/shared/Stubs/TreeNodeStateTransactionStub.ts @@ -0,0 +1,32 @@ +import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState'; +import { TreeNodeStateTransaction } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess'; +import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor'; + +export class TreeNodeStateTransactionStub implements TreeNodeStateTransaction { + public withExpansionState(isExpanded: boolean): TreeNodeStateTransaction { + this.updatedState = { ...this.updatedState, isExpanded }; + return this; + } + + public withMatchState(isMatched: boolean): TreeNodeStateTransaction { + this.updatedState = { ...this.updatedState, isMatched }; + return this; + } + + public withFocusState(isFocused: boolean): TreeNodeStateTransaction { + this.updatedState = { ...this.updatedState, isFocused }; + return this; + } + + public withVisibilityState(isVisible: boolean): TreeNodeStateTransaction { + this.updatedState = { ...this.updatedState, isVisible }; + return this; + } + + public withCheckState(checkState: TreeNodeCheckState): TreeNodeStateTransaction { + this.updatedState = { ...this.updatedState, checkState }; + return this; + } + + public updatedState: Partial; +} diff --git a/tests/unit/shared/Stubs/TreeNodeStub.ts b/tests/unit/shared/Stubs/TreeNodeStub.ts new file mode 100644 index 00000000..82c80e49 --- /dev/null +++ b/tests/unit/shared/Stubs/TreeNodeStub.ts @@ -0,0 +1,35 @@ +import { HierarchyAccess } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/HierarchyAccess'; +import { TreeNodeStateAccess } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess'; +import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode'; +import { NodeMetadataStub } from './NodeMetadataStub'; +import { HierarchyAccessStub } from './HierarchyAccessStub'; + +export class TreeNodeStub implements TreeNode { + public state: TreeNodeStateAccess; + + public hierarchy: HierarchyAccess = new HierarchyAccessStub(); + + public id: string; + + public metadata?: object = new NodeMetadataStub(); + + public withMetadata(metadata: object): this { + this.metadata = metadata; + return this; + } + + public withHierarchy(hierarchy: HierarchyAccess): this { + this.hierarchy = hierarchy; + return this; + } + + public withState(state: TreeNodeStateAccess): this { + this.state = state; + return this; + } + + public withId(id: string): this { + this.id = id; + return this; + } +} diff --git a/tests/unit/shared/Stubs/TreeRootStub.ts b/tests/unit/shared/Stubs/TreeRootStub.ts new file mode 100644 index 00000000..4292250f --- /dev/null +++ b/tests/unit/shared/Stubs/TreeRootStub.ts @@ -0,0 +1,16 @@ +import { SingleNodeFocusManager } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeFocusManager'; +import { TreeNodeCollection } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeCollection'; +import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot'; +import { TreeNodeCollectionStub } from './TreeNodeCollectionStub'; +import { SingleNodeFocusManagerStub } from './SingleNodeFocusManagerStub'; + +export class TreeRootStub implements TreeRoot { + public collection: TreeNodeCollection = new TreeNodeCollectionStub(); + + public focus: SingleNodeFocusManager = new SingleNodeFocusManagerStub(); + + public withCollection(collection: TreeNodeCollection): this { + this.collection = collection; + return this; + } +} diff --git a/tests/unit/shared/Stubs/UseCollectionStateStub.ts b/tests/unit/shared/Stubs/UseCollectionStateStub.ts index 444feca7..cb1a65ab 100644 --- a/tests/unit/shared/Stubs/UseCollectionStateStub.ts +++ b/tests/unit/shared/Stubs/UseCollectionStateStub.ts @@ -11,14 +11,14 @@ import { CategoryCollectionStateStub } from './CategoryCollectionStateStub'; import { EventSubscriptionCollectionStub } from './EventSubscriptionCollectionStub'; import { ApplicationContextStub } from './ApplicationContextStub'; import { UserFilterStub } from './UserFilterStub'; +import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; -export class UseCollectionStateStub { +export class UseCollectionStateStub + extends StubWithObservableMethodCalls> { private currentContext: IApplicationContext = new ApplicationContextStub(); private readonly currentState = ref(new CategoryCollectionStateStub()); - private readonly onStateChangeHandlers = new Array(); - public withFilter(filter: IUserFilter) { const state = new CategoryCollectionStateStub() .withFilter(filter); @@ -49,10 +49,18 @@ export class UseCollectionStateStub { return this.currentState.value; } - public triggerOnStateChange(newState: ICategoryCollectionState): void { - this.currentState.value = newState; - this.onStateChangeHandlers.forEach( - (handler) => handler(newState, undefined), + public triggerOnStateChange(scenario: { + readonly newState: ICategoryCollectionState, + readonly immediateOnly: boolean, + }): void { + this.currentState.value = scenario.newState; + let calls = this.callHistory.filter((call) => call.methodName === 'onStateChange'); + if (scenario.immediateOnly) { + calls = calls.filter((call) => call.args[1].immediate === true); + } + const handlers = calls.map((call) => call.args[0] as NewStateEventHandler); + handlers.forEach( + (handler) => handler(scenario.newState, undefined), ); } @@ -63,15 +71,26 @@ export class UseCollectionStateStub { if (settings?.immediate) { handler(this.currentState.value, undefined); } - this.onStateChangeHandlers.push(handler); + this.registerMethodCall({ + methodName: 'onStateChange', + args: [handler, settings], + }); } private modifyCurrentState(mutator: StateModifier) { mutator(this.currentState.value); + this.registerMethodCall({ + methodName: 'modifyCurrentState', + args: [mutator], + }); } private modifyCurrentContext(mutator: ContextModifier) { mutator(this.currentContext); + this.registerMethodCall({ + methodName: 'modifyCurrentContext', + args: [mutator], + }); } public get(): ReturnType { diff --git a/tests/unit/shared/Stubs/UserSelectionStub.ts b/tests/unit/shared/Stubs/UserSelectionStub.ts index 2531c306..8e38057b 100644 --- a/tests/unit/shared/Stubs/UserSelectionStub.ts +++ b/tests/unit/shared/Stubs/UserSelectionStub.ts @@ -3,19 +3,23 @@ import { SelectedScript } from '@/application/Context/State/Selection/SelectedSc import { IScript } from '@/domain/IScript'; import { IEventSource } from '@/infrastructure/Events/IEventSource'; import { EventSource } from '@/infrastructure/Events/EventSource'; +import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; -export class UserSelectionStub implements IUserSelection { +export class UserSelectionStub + extends StubWithObservableMethodCalls + implements IUserSelection { public readonly changed: IEventSource = new EventSource< readonly SelectedScript[]>(); public selectedScripts: readonly SelectedScript[] = []; constructor(private readonly allScripts: readonly IScript[]) { - + super(); } - public withSelectedScripts(selectedScripts: readonly SelectedScript[]) { + public withSelectedScripts(selectedScripts: readonly SelectedScript[]): this { this.selectedScripts = selectedScripts; + return this; } public areAllSelected(): boolean { @@ -34,16 +38,22 @@ export class UserSelectionStub implements IUserSelection { throw new Error('Method not implemented.'); } - public addSelectedScript(): void { - throw new Error('Method not implemented.'); + public addSelectedScript(scriptId: string, revert: boolean): void { + this.registerMethodCall({ + methodName: 'addSelectedScript', + args: [scriptId, revert], + }); } public addOrUpdateSelectedScript(): void { throw new Error('Method not implemented.'); } - public removeSelectedScript(): void { - throw new Error('Method not implemented.'); + public removeSelectedScript(scriptId: string): void { + this.registerMethodCall({ + methodName: 'removeSelectedScript', + args: [scriptId], + }); } public selectOnly(scripts: ReadonlyArray): void {