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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user