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:
undergroundwires
2023-09-09 22:26:21 +02:00
parent 821cc62c4c
commit 65f121c451
120 changed files with 4537 additions and 1203 deletions

View File

@@ -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;
}

View File

@@ -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);
});
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});
});

View File

@@ -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);
});
});
});
});

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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),
));
}

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});

View File

@@ -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);
}
}

View File

@@ -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);
});
});
});

View File

@@ -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,
};
}