Introduce new TreeView UI component
Key highlights: - Written from scratch to cater specifically to privacy.sexy's needs and requirements. - The visual look mimics the previous component with minimal changes, but its internal code is completely rewritten. - Lays groundwork for future functionalities like the "expand all" button a flat view mode as discussed in #158. - Facilitates the transition to Vue 3 by omitting the Vue 2.0 dependent `liquour-tree` as part of #230. Improvements and features: - Caching for quicker node queries. - Gradual rendering of nodes that introduces a noticable boost in performance, particularly during search/filtering. - `TreeView` solely governs the check states of branch nodes. Changes: - Keyboard interactions now alter the background color to highlight the focused item. Previously, it was changing the color of the text. - Better state management with clear separation of concerns: - `TreeView` exclusively manages indeterminate states. - `TreeView` solely governs the check states of branch nodes. - Introduce transaction pattern to update state in batches to minimize amount of events handled. - Improve keyboard focus, style background instead of foreground. Use hover/touch color on keyboard focus. - `SelectableTree` has been removed. Instead, `TreeView` is now directly integrated with `ScriptsTree`. - `ScriptsTree` has been refactored to incorporate hooks for clearer code and separation of duties. - Adopt Vue-idiomatic bindings instead of keeping a reference of the tree component. - Simplify and change filter event management. - Abandon global styles in favor of class-scoped styles. - Use global mixins with descriptive names to clarify indended functionality.
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -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', () => {
|
||||
@@ -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',
|
||||
@@ -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', () => {
|
||||
@@ -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';
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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: [],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
}
|
||||
@@ -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<TreeViewFilterEvent>();
|
||||
// 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<TreeViewFilterEvent>();
|
||||
// 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TreeNodeStateDescriptor>]: (
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TreeNodeStateDescriptor>]: {
|
||||
readonly applyStateChange: (
|
||||
describer: TreeNodeStateTransaction,
|
||||
) => TreeNodeStateTransaction,
|
||||
readonly extractStateValue: (descriptor: Partial<TreeNodeStateDescriptor>) => 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof useKeyboardInteractionState>;
|
||||
const wrapper = shallowMount(defineComponent({
|
||||
setup() {
|
||||
returnObject = useKeyboardInteractionState(window);
|
||||
},
|
||||
template: '<div></div>',
|
||||
}));
|
||||
return {
|
||||
returnObject,
|
||||
wrapper,
|
||||
};
|
||||
}
|
||||
|
||||
type EventListenerWindowFunction = (ev: Event) => unknown;
|
||||
type WindowEventKey = keyof WindowEventMap;
|
||||
|
||||
function createWindowStub() {
|
||||
const listeners: Partial<Record<WindowEventKey, EventListenerWindowFunction[]>> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<ReadOnlyTreeNode | undefined>(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<ReadOnlyTreeNode | undefined>(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<ReadOnlyTreeNode | undefined>(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<ReadOnlyTreeNode | undefined>(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<ReadOnlyTreeNode | undefined>) {
|
||||
let returnObject: ReturnType<typeof useNodeState>;
|
||||
const wrapper = shallowMount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
returnObject = useNodeState(nodeWatcher);
|
||||
},
|
||||
template: '<div></div>',
|
||||
}),
|
||||
{
|
||||
provide: {
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
wrapper,
|
||||
returnObject,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TreeRoot>(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<TreeRoot | undefined>) {
|
||||
let returnObject: ReturnType<typeof useCurrentTreeNodes>;
|
||||
const wrapper = shallowMount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
returnObject = useCurrentTreeNodes(treeWatcher);
|
||||
},
|
||||
template: '<div></div>',
|
||||
}),
|
||||
{
|
||||
provide: {
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
wrapper,
|
||||
returnObject,
|
||||
};
|
||||
}
|
||||
@@ -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'));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof useCollectionSelectionStateUpdater>;
|
||||
|
||||
shallowMount({
|
||||
setup() {
|
||||
returnObject = useCollectionSelectionStateUpdater();
|
||||
},
|
||||
template: '<div></div>',
|
||||
}, {
|
||||
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,
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -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<IScript, string>([
|
||||
[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<typeof useSelectedScriptNodeIds>;
|
||||
|
||||
shallowMount({
|
||||
setup() {
|
||||
returnObject = useSelectedScriptNodeIds(nodeIdParser);
|
||||
},
|
||||
template: '<div></div>',
|
||||
}, {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState as symbol]:
|
||||
() => useStateStub.get(),
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
returnObject,
|
||||
useStateStub,
|
||||
};
|
||||
}
|
||||
@@ -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<number | undefined>();
|
||||
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<number | undefined>();
|
||||
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<number | undefined>) {
|
||||
const useStateStub = new UseCollectionStateStub();
|
||||
const parserMock = mockCategoryNodeParser();
|
||||
const converterMock = mockConverter();
|
||||
let returnObject: ReturnType<typeof useTreeViewNodeInput>;
|
||||
|
||||
shallowMount({
|
||||
setup() {
|
||||
returnObject = useTreeViewNodeInput(categoryIdWatcher, parserMock.mock, converterMock.mock);
|
||||
},
|
||||
template: '<div></div>',
|
||||
}, {
|
||||
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<ConversionScenario>();
|
||||
|
||||
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<ParseSingleScenario>();
|
||||
|
||||
const parseAllScenarios = new Array<ParseAllScenario>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
42
tests/unit/shared/Stubs/HierarchyAccessStub.ts
Normal file
42
tests/unit/shared/Stubs/HierarchyAccessStub.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
25
tests/unit/shared/Stubs/NodeMetadataStub.ts
Normal file
25
tests/unit/shared/Stubs/NodeMetadataStub.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
33
tests/unit/shared/Stubs/NodeStateChangedEventStub.ts
Normal file
33
tests/unit/shared/Stubs/NodeStateChangedEventStub.ts
Normal file
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
22
tests/unit/shared/Stubs/QueryableNodesStub.ts
Normal file
22
tests/unit/shared/Stubs/QueryableNodesStub.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
9
tests/unit/shared/Stubs/SingleNodeFocusManagerStub.ts
Normal file
9
tests/unit/shared/Stubs/SingleNodeFocusManagerStub.ts
Normal file
@@ -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 */ }
|
||||
}
|
||||
26
tests/unit/shared/Stubs/TreeInputNodeDataStub.ts
Normal file
26
tests/unit/shared/Stubs/TreeInputNodeDataStub.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
23
tests/unit/shared/Stubs/TreeNodeCollectionStub.ts
Normal file
23
tests/unit/shared/Stubs/TreeNodeCollectionStub.ts
Normal file
@@ -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<QueryableNodes>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
29
tests/unit/shared/Stubs/TreeNodeParserStub.ts
Normal file
29
tests/unit/shared/Stubs/TreeNodeParserStub.ts
Normal file
@@ -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<StubScenario>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
45
tests/unit/shared/Stubs/TreeNodeStateAccessStub.ts
Normal file
45
tests/unit/shared/Stubs/TreeNodeStateAccessStub.ts
Normal file
@@ -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<NodeStateChangedEvent> = 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;
|
||||
}
|
||||
}
|
||||
24
tests/unit/shared/Stubs/TreeNodeStateDescriptorStub.ts
Normal file
24
tests/unit/shared/Stubs/TreeNodeStateDescriptorStub.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
32
tests/unit/shared/Stubs/TreeNodeStateTransactionStub.ts
Normal file
32
tests/unit/shared/Stubs/TreeNodeStateTransactionStub.ts
Normal file
@@ -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<TreeNodeStateDescriptor>;
|
||||
}
|
||||
35
tests/unit/shared/Stubs/TreeNodeStub.ts
Normal file
35
tests/unit/shared/Stubs/TreeNodeStub.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
16
tests/unit/shared/Stubs/TreeRootStub.ts
Normal file
16
tests/unit/shared/Stubs/TreeRootStub.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ReturnType<typeof useCollectionState>> {
|
||||
private currentContext: IApplicationContext = new ApplicationContextStub();
|
||||
|
||||
private readonly currentState = ref<ICategoryCollectionState>(new CategoryCollectionStateStub());
|
||||
|
||||
private readonly onStateChangeHandlers = new Array<NewStateEventHandler>();
|
||||
|
||||
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<typeof useCollectionState> {
|
||||
|
||||
@@ -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<IUserSelection>
|
||||
implements IUserSelection {
|
||||
public readonly changed: IEventSource<readonly SelectedScript[]> = 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<IScript>): void {
|
||||
|
||||
Reference in New Issue
Block a user