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,136 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
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('CategoryNodeMetadataConverter', () => {
|
||||
it('can convert script id and back', () => {
|
||||
// arrange
|
||||
const script = new ScriptStub('test');
|
||||
// act
|
||||
const nodeId = getScriptNodeId(script);
|
||||
const scriptId = getScriptId(nodeId);
|
||||
// assert
|
||||
expect(scriptId).to.equal(script.id);
|
||||
});
|
||||
it('can convert category id and back', () => {
|
||||
// arrange
|
||||
const category = new CategoryStub(55);
|
||||
// act
|
||||
const nodeId = getCategoryNodeId(category);
|
||||
const scriptId = getCategoryId(nodeId);
|
||||
// assert
|
||||
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;
|
||||
const firstSubCategory = new CategoryStub(11).withScriptIds('111', '112');
|
||||
const secondSubCategory = new CategoryStub(categoryId)
|
||||
.withCategory(new CategoryStub(33).withScriptIds('331', '331'))
|
||||
.withCategory(new CategoryStub(44).withScriptIds('44'));
|
||||
const collection = new CategoryCollectionStub().withAction(new CategoryStub(categoryId)
|
||||
.withCategory(firstSubCategory)
|
||||
.withCategory(secondSubCategory));
|
||||
// act
|
||||
const nodes = parseSingleCategory(categoryId, collection);
|
||||
// assert
|
||||
expect(nodes).to.have.lengthOf(2);
|
||||
expectSameCategory(nodes[0], firstSubCategory);
|
||||
expectSameCategory(nodes[1], secondSubCategory);
|
||||
});
|
||||
it('can parse when category has sub scripts', () => {
|
||||
// arrange
|
||||
const categoryId = 31;
|
||||
const scripts = [new ScriptStub('script1'), new ScriptStub('script2'), new ScriptStub('script3')];
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(categoryId).withScripts(...scripts));
|
||||
// act
|
||||
const nodes = parseSingleCategory(categoryId, collection);
|
||||
// assert
|
||||
expect(nodes).to.have.lengthOf(3);
|
||||
expectSameScript(nodes[0], scripts[0]);
|
||||
expectSameScript(nodes[1], scripts[1]);
|
||||
expectSameScript(nodes[2], scripts[2]);
|
||||
});
|
||||
});
|
||||
it('parseAllCategories parses as expected', () => {
|
||||
// arrange
|
||||
const collection = new CategoryCollectionStub()
|
||||
.withAction(new CategoryStub(0).withScriptIds('1, 2'))
|
||||
.withAction(new CategoryStub(1).withCategories(
|
||||
new CategoryStub(3).withScriptIds('3', '4'),
|
||||
new CategoryStub(4).withCategory(new CategoryStub(5).withScriptIds('6')),
|
||||
));
|
||||
// act
|
||||
const nodes = parseAllCategories(collection);
|
||||
// assert
|
||||
expect(nodes).to.have.lengthOf(2);
|
||||
expectSameCategory(nodes[0], collection.actions[0]);
|
||||
expectSameCategory(nodes[1], collection.actions[1]);
|
||||
});
|
||||
});
|
||||
|
||||
function isReversible(category: ICategory): boolean {
|
||||
if (category.scripts) {
|
||||
return category.scripts.every((s) => s.canRevert());
|
||||
}
|
||||
return category.subCategories.every((c) => isReversible(c));
|
||||
}
|
||||
|
||||
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'));
|
||||
expect(node.text).to.equal(category.name, getErrorMessage('name'));
|
||||
expect(node.isReversible).to.equal(isReversible(category), getErrorMessage('isReversible'));
|
||||
expect(node.children).to.have.lengthOf(category.scripts.length || category.subCategories.length, getErrorMessage('name'));
|
||||
for (let i = 0; i < category.subCategories.length; i++) {
|
||||
expectSameCategory(node.children[i], category.subCategories[i]);
|
||||
}
|
||||
for (let i = 0; i < category.scripts.length; i++) {
|
||||
expectSameScript(node.children[i], category.scripts[i]);
|
||||
}
|
||||
function getErrorMessage(field: string) {
|
||||
return `Unexpected node field: ${field}.\n`
|
||||
+ `\nActual node:\n${print(node)}`
|
||||
+ `\nExpected category:\n${print(category)}`;
|
||||
}
|
||||
}
|
||||
|
||||
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'));
|
||||
expect(node.text).to.equal(script.name, getErrorMessage('name'));
|
||||
expect(node.isReversible).to.equal(script.canRevert(), getErrorMessage('canRevert'));
|
||||
expect(node.children).to.equal(undefined);
|
||||
function getErrorMessage(field: string) {
|
||||
return `Unexpected node field: ${field}.`
|
||||
+ `\nActual node:\n${print(node)}\n`
|
||||
+ `\nExpected script:\n${print(script)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function print(object: unknown) {
|
||||
return JSON.stringify(object, null, 2);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user