Fix loss of tree node state when switching views
This commit fixes an issue where the check state of categories was lost when toggling between card and tree views. This is solved by immediately emitting node state changes for all nodes. This ensures consistent view transitions without any loss of node state information. Furthermore, this commit includes added unit tests for the modified code sections.
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { NodeStateChangedEvent } from '../Node/State/StateAccess';
|
import { TreeNodeStateDescriptor } from '../Node/State/StateDescriptor';
|
||||||
import { ReadOnlyTreeNode } from '../Node/TreeNode';
|
import { ReadOnlyTreeNode } from '../Node/TreeNode';
|
||||||
|
|
||||||
export interface TreeNodeStateChangedEmittedEvent {
|
export interface TreeNodeStateChangedEmittedEvent {
|
||||||
readonly change: NodeStateChangedEvent;
|
|
||||||
readonly node: ReadOnlyTreeNode;
|
readonly node: ReadOnlyTreeNode;
|
||||||
|
readonly oldState?: TreeNodeStateDescriptor;
|
||||||
|
readonly newState: TreeNodeStateDescriptor;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface DelayScheduler {
|
||||||
|
scheduleNext(callback: () => void, delayInMs: number): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { DelayScheduler } from './DelayScheduler';
|
||||||
|
|
||||||
|
export class TimeoutDelayScheduler implements DelayScheduler {
|
||||||
|
private timeoutId: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||||
|
|
||||||
|
constructor(private readonly timer: TimeFunctions = {
|
||||||
|
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||||
|
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
public scheduleNext(callback: () => void, delayInMs: number): void {
|
||||||
|
this.clear();
|
||||||
|
this.timeoutId = this.timer.setTimeout(callback, delayInMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clear(): void {
|
||||||
|
if (this.timeoutId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.timer.clearTimeout(this.timeoutId);
|
||||||
|
this.timeoutId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeFunctions {
|
||||||
|
clearTimeout(id: ReturnType<typeof setTimeout>): void;
|
||||||
|
setTimeout(callback: () => void, delayInMs: number): ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
@@ -6,39 +6,37 @@ import { useNodeStateChangeAggregator } from '../UseNodeStateChangeAggregator';
|
|||||||
import { TreeRoot } from '../TreeRoot/TreeRoot';
|
import { TreeRoot } from '../TreeRoot/TreeRoot';
|
||||||
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
|
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
|
||||||
import { NodeRenderingStrategy } from './NodeRenderingStrategy';
|
import { NodeRenderingStrategy } from './NodeRenderingStrategy';
|
||||||
|
import { DelayScheduler } from './DelayScheduler';
|
||||||
|
import { TimeoutDelayScheduler } from './TimeoutDelayScheduler';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders tree nodes gradually to prevent UI freeze when loading large amounts of nodes.
|
* Renders tree nodes gradually to prevent UI freeze when loading large amounts of nodes.
|
||||||
*/
|
*/
|
||||||
export function useGradualNodeRendering(
|
export function useGradualNodeRendering(
|
||||||
treeWatcher: WatchSource<TreeRoot>,
|
treeWatcher: WatchSource<TreeRoot>,
|
||||||
|
useChangeAggregator = useNodeStateChangeAggregator,
|
||||||
|
useTreeNodes = useCurrentTreeNodes,
|
||||||
|
scheduler: DelayScheduler = new TimeoutDelayScheduler(),
|
||||||
|
initialBatchSize = 30,
|
||||||
|
subsequentBatchSize = 5,
|
||||||
): NodeRenderingStrategy {
|
): NodeRenderingStrategy {
|
||||||
const nodesToRender = new Set<ReadOnlyTreeNode>();
|
const nodesToRender = new Set<ReadOnlyTreeNode>();
|
||||||
const nodesBeingRendered = shallowRef(new Set<ReadOnlyTreeNode>());
|
const nodesBeingRendered = shallowRef(new Set<ReadOnlyTreeNode>());
|
||||||
let isFirstRender = true;
|
|
||||||
let isRenderingInProgress = false;
|
let isRenderingInProgress = false;
|
||||||
const renderingDelayInMs = 50;
|
const renderingDelayInMs = 50;
|
||||||
const initialBatchSize = 30;
|
|
||||||
const subsequentBatchSize = 5;
|
|
||||||
|
|
||||||
const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher);
|
const { onNodeStateChange } = useChangeAggregator(treeWatcher);
|
||||||
const { nodes } = useCurrentTreeNodes(treeWatcher);
|
const { nodes } = useTreeNodes(treeWatcher);
|
||||||
|
|
||||||
const orderedNodes = computed<readonly ReadOnlyTreeNode[]>(() => nodes.value.flattenedNodes);
|
const orderedNodes = computed<readonly ReadOnlyTreeNode[]>(() => nodes.value.flattenedNodes);
|
||||||
|
|
||||||
watch(() => orderedNodes.value, (newNodes) => {
|
function updateNodeRenderQueue(node: ReadOnlyTreeNode, isVisible: boolean) {
|
||||||
newNodes.forEach((node) => updateNodeRenderQueue(node));
|
if (isVisible
|
||||||
}, { immediate: true });
|
|
||||||
|
|
||||||
function updateNodeRenderQueue(node: ReadOnlyTreeNode) {
|
|
||||||
if (node.state.current.isVisible
|
|
||||||
&& !nodesToRender.has(node)
|
&& !nodesToRender.has(node)
|
||||||
&& !nodesBeingRendered.value.has(node)) {
|
&& !nodesBeingRendered.value.has(node)) {
|
||||||
nodesToRender.add(node);
|
nodesToRender.add(node);
|
||||||
if (!isRenderingInProgress) {
|
beginRendering();
|
||||||
scheduleRendering();
|
} else if (!isVisible) {
|
||||||
}
|
|
||||||
} else if (!node.state.current.isVisible) {
|
|
||||||
if (nodesToRender.has(node)) {
|
if (nodesToRender.has(node)) {
|
||||||
nodesToRender.delete(node);
|
nodesToRender.delete(node);
|
||||||
}
|
}
|
||||||
@@ -49,47 +47,58 @@ export function useGradualNodeRendering(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onNodeStateChange((node, change) => {
|
watch(() => orderedNodes.value, (newNodes) => {
|
||||||
if (change.newState.isVisible === change.oldState.isVisible) {
|
nodesToRender.clear();
|
||||||
|
nodesBeingRendered.value.clear();
|
||||||
|
if (!newNodes?.length) {
|
||||||
|
triggerRef(nodesBeingRendered);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateNodeRenderQueue(node);
|
newNodes
|
||||||
|
.filter((node) => node.state.current.isVisible)
|
||||||
|
.forEach((node) => nodesToRender.add(node));
|
||||||
|
beginRendering();
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
onNodeStateChange((change) => {
|
||||||
|
if (change.newState.isVisible === change.oldState?.isVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateNodeRenderQueue(change.node, change.newState.isVisible);
|
||||||
});
|
});
|
||||||
|
|
||||||
scheduleRendering();
|
function beginRendering() {
|
||||||
|
if (isRenderingInProgress) {
|
||||||
function scheduleRendering() {
|
return;
|
||||||
if (isFirstRender) {
|
|
||||||
renderNodeBatch();
|
|
||||||
isFirstRender = false;
|
|
||||||
} else {
|
|
||||||
const delayScheduler = new DelayScheduler(renderingDelayInMs);
|
|
||||||
delayScheduler.schedule(renderNodeBatch);
|
|
||||||
}
|
}
|
||||||
|
renderNextBatch(initialBatchSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNodeBatch() {
|
function renderNextBatch(batchSize: number) {
|
||||||
if (nodesToRender.size === 0) {
|
if (nodesToRender.size === 0) {
|
||||||
isRenderingInProgress = false;
|
isRenderingInProgress = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isRenderingInProgress = true;
|
isRenderingInProgress = true;
|
||||||
const batchSize = isFirstRender ? initialBatchSize : subsequentBatchSize;
|
|
||||||
const sortedNodes = Array.from(nodesToRender).sort(
|
const sortedNodes = Array.from(nodesToRender).sort(
|
||||||
(a, b) => orderedNodes.value.indexOf(a) - orderedNodes.value.indexOf(b),
|
(a, b) => orderedNodes.value.indexOf(a) - orderedNodes.value.indexOf(b),
|
||||||
);
|
);
|
||||||
const currentBatch = sortedNodes.slice(0, batchSize);
|
const currentBatch = sortedNodes.slice(0, batchSize);
|
||||||
|
if (currentBatch.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
currentBatch.forEach((node) => {
|
currentBatch.forEach((node) => {
|
||||||
nodesToRender.delete(node);
|
nodesToRender.delete(node);
|
||||||
nodesBeingRendered.value.add(node);
|
nodesBeingRendered.value.add(node);
|
||||||
});
|
});
|
||||||
triggerRef(nodesBeingRendered);
|
triggerRef(nodesBeingRendered);
|
||||||
if (nodesToRender.size > 0) {
|
scheduler.scheduleNext(
|
||||||
scheduleRendering();
|
() => renderNextBatch(subsequentBatchSize),
|
||||||
}
|
renderingDelayInMs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldNodeBeRendered(node: ReadOnlyTreeNode) {
|
function shouldNodeBeRendered(node: ReadOnlyTreeNode): boolean {
|
||||||
return nodesBeingRendered.value.has(node);
|
return nodesBeingRendered.value.has(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,21 +106,3 @@ export function useGradualNodeRendering(
|
|||||||
shouldRender: shouldNodeBeRendered,
|
shouldRender: shouldNodeBeRendered,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class DelayScheduler {
|
|
||||||
private timeoutId: ReturnType<typeof setTimeout> = null;
|
|
||||||
|
|
||||||
constructor(private delay: number) {}
|
|
||||||
|
|
||||||
schedule(callback: () => void) {
|
|
||||||
this.clear();
|
|
||||||
this.timeoutId = setTimeout(callback, this.delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
if (this.timeoutId) {
|
|
||||||
clearTimeout(this.timeoutId);
|
|
||||||
this.timeoutId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -68,8 +68,13 @@ export default defineComponent({
|
|||||||
const nodeRenderingScheduler = useGradualNodeRendering(() => tree);
|
const nodeRenderingScheduler = useGradualNodeRendering(() => tree);
|
||||||
|
|
||||||
const { onNodeStateChange } = useNodeStateChangeAggregator(() => tree);
|
const { onNodeStateChange } = useNodeStateChangeAggregator(() => tree);
|
||||||
onNodeStateChange((node, change) => {
|
|
||||||
emit('nodeStateChanged', { node, change });
|
onNodeStateChange((change) => {
|
||||||
|
emit('nodeStateChanged', {
|
||||||
|
node: change.node,
|
||||||
|
newState: change.newState,
|
||||||
|
oldState: change.oldState,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ import { TreeNodeCheckState } from './Node/State/CheckState';
|
|||||||
|
|
||||||
export function useAutoUpdateChildrenCheckState(
|
export function useAutoUpdateChildrenCheckState(
|
||||||
treeWatcher: WatchSource<TreeRoot>,
|
treeWatcher: WatchSource<TreeRoot>,
|
||||||
|
useChangeAggregator = useNodeStateChangeAggregator,
|
||||||
) {
|
) {
|
||||||
const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher);
|
const { onNodeStateChange } = useChangeAggregator(treeWatcher);
|
||||||
|
|
||||||
onNodeStateChange((node, change) => {
|
onNodeStateChange((change) => {
|
||||||
if (change.newState.checkState === change.oldState.checkState) {
|
if (change.newState.checkState === change.oldState?.checkState) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateChildrenCheckedState(node.hierarchy, change.newState.checkState);
|
updateChildrenCheckedState(change.node.hierarchy, change.newState.checkState);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ import { ReadOnlyTreeNode } from './Node/TreeNode';
|
|||||||
|
|
||||||
export function useAutoUpdateParentCheckState(
|
export function useAutoUpdateParentCheckState(
|
||||||
treeWatcher: WatchSource<TreeRoot>,
|
treeWatcher: WatchSource<TreeRoot>,
|
||||||
|
useChangeAggregator = useNodeStateChangeAggregator,
|
||||||
) {
|
) {
|
||||||
const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher);
|
const { onNodeStateChange } = useChangeAggregator(treeWatcher);
|
||||||
|
|
||||||
onNodeStateChange((node, change) => {
|
onNodeStateChange((change) => {
|
||||||
if (change.newState.checkState === change.oldState.checkState) {
|
if (change.newState.checkState === change.oldState?.checkState) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateNodeParentCheckedState(node.hierarchy);
|
updateNodeParentCheckedState(change.node.hierarchy);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { QueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes';
|
|||||||
export function useCurrentTreeNodes(treeWatcher: WatchSource<TreeRoot>) {
|
export function useCurrentTreeNodes(treeWatcher: WatchSource<TreeRoot>) {
|
||||||
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
|
||||||
const tree = ref<TreeRoot>();
|
const tree = ref<TreeRoot | undefined>();
|
||||||
const nodes = ref<QueryableNodes | undefined>();
|
const nodes = ref<QueryableNodes | undefined>();
|
||||||
|
|
||||||
watch(treeWatcher, (newTree) => {
|
watch(treeWatcher, (newTree) => {
|
||||||
|
|||||||
@@ -1,35 +1,83 @@
|
|||||||
import { WatchSource, inject, watch } from 'vue';
|
import {
|
||||||
|
WatchSource, inject, watch, ref,
|
||||||
|
} from 'vue';
|
||||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||||
import { TreeRoot } from './TreeRoot/TreeRoot';
|
import { TreeRoot } from './TreeRoot/TreeRoot';
|
||||||
import { TreeNode } from './Node/TreeNode';
|
import { TreeNode } from './Node/TreeNode';
|
||||||
import { useCurrentTreeNodes } from './UseCurrentTreeNodes';
|
import { useCurrentTreeNodes } from './UseCurrentTreeNodes';
|
||||||
import { NodeStateChangedEvent } from './Node/State/StateAccess';
|
import { TreeNodeStateDescriptor } from './Node/State/StateDescriptor';
|
||||||
|
|
||||||
type NodeStateChangeEventCallback = (
|
export type NodeStateChangeEventCallback = (args: NodeStateChangeEventArgs) => void;
|
||||||
node: TreeNode,
|
|
||||||
stateChange: NodeStateChangedEvent,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
export function useNodeStateChangeAggregator(treeWatcher: WatchSource<TreeRoot>) {
|
export function useNodeStateChangeAggregator(
|
||||||
const { nodes } = useCurrentTreeNodes(treeWatcher);
|
treeWatcher: WatchSource<TreeRoot>,
|
||||||
|
useTreeNodes = useCurrentTreeNodes,
|
||||||
|
) {
|
||||||
|
const { nodes } = useTreeNodes(treeWatcher);
|
||||||
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
|
||||||
const onNodeChangeCallbacks = new Array<NodeStateChangeEventCallback>();
|
const onNodeChangeCallback = ref<NodeStateChangeEventCallback>();
|
||||||
|
|
||||||
watch(() => nodes.value, (newNodes) => {
|
watch([
|
||||||
events.unsubscribeAll();
|
() => nodes.value,
|
||||||
newNodes.flattenedNodes.forEach((node) => {
|
() => onNodeChangeCallback.value,
|
||||||
events.register([
|
], ([newNodes, callback]) => {
|
||||||
node.state.changed.on((stateChange) => {
|
if (!callback) { // might not be registered yet
|
||||||
onNodeChangeCallbacks.forEach((callback) => callback(node, stateChange));
|
return;
|
||||||
}),
|
}
|
||||||
]);
|
if (!newNodes || newNodes.flattenedNodes.length === 0) {
|
||||||
});
|
events.unsubscribeAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allNodes = newNodes.flattenedNodes;
|
||||||
|
events.unsubscribeAllAndRegister(
|
||||||
|
subscribeToNotifyOnFutureNodeChanges(allNodes, callback),
|
||||||
|
);
|
||||||
|
notifyCurrentNodeState(allNodes, callback);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function onNodeStateChange(
|
||||||
|
callback: NodeStateChangeEventCallback,
|
||||||
|
): void {
|
||||||
|
if (!callback) {
|
||||||
|
throw new Error('missing callback');
|
||||||
|
}
|
||||||
|
onNodeChangeCallback.value = callback;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onNodeStateChange: (
|
onNodeStateChange,
|
||||||
callback: NodeStateChangeEventCallback,
|
|
||||||
) => onNodeChangeCallbacks.push(callback),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NodeStateChangeEventArgs {
|
||||||
|
readonly node: TreeNode;
|
||||||
|
readonly newState: TreeNodeStateDescriptor;
|
||||||
|
readonly oldState?: TreeNodeStateDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyCurrentNodeState(
|
||||||
|
nodes: readonly TreeNode[],
|
||||||
|
callback: NodeStateChangeEventCallback,
|
||||||
|
) {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
callback({
|
||||||
|
node,
|
||||||
|
newState: node.state.current,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeToNotifyOnFutureNodeChanges(
|
||||||
|
nodes: readonly TreeNode[],
|
||||||
|
callback: NodeStateChangeEventCallback,
|
||||||
|
): IEventSubscription[] {
|
||||||
|
return nodes.map((node) => node.state.changed.on((stateChange) => {
|
||||||
|
callback({
|
||||||
|
node,
|
||||||
|
oldState: stateChange.oldState,
|
||||||
|
newState: stateChange.newState,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { TreeNodeStateChangedEmittedEvent } from '../TreeView/Bindings/TreeNodeS
|
|||||||
export function useCollectionSelectionStateUpdater() {
|
export function useCollectionSelectionStateUpdater() {
|
||||||
const { modifyCurrentState, currentState } = inject(InjectionKeys.useCollectionState)();
|
const { modifyCurrentState, currentState } = inject(InjectionKeys.useCollectionState)();
|
||||||
|
|
||||||
const updateNodeSelection = (event: TreeNodeStateChangedEmittedEvent) => {
|
function updateNodeSelection(change: TreeNodeStateChangedEmittedEvent) {
|
||||||
const { node } = event;
|
const { node } = change;
|
||||||
if (node.hierarchy.isBranchNode) {
|
if (node.hierarchy.isBranchNode) {
|
||||||
return; // A category, let TreeView handle this
|
return; // A category, let TreeView handle this
|
||||||
}
|
}
|
||||||
if (event.change.oldState.checkState === event.change.newState.checkState) {
|
if (change.oldState?.checkState === change.newState.checkState) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (node.state.current.checkState === TreeNodeCheckState.Checked) {
|
if (node.state.current.checkState === TreeNodeCheckState.Checked) {
|
||||||
@@ -30,7 +30,7 @@ export function useCollectionSelectionStateUpdater() {
|
|||||||
state.selection.removeSelectedScript(node.id);
|
state.selection.removeSelectedScript(node.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updateNodeSelection,
|
updateNodeSelection,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TimeFunctions, TimeoutDelayScheduler } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/TimeoutDelayScheduler';
|
||||||
|
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
|
||||||
|
|
||||||
|
describe('TimeoutDelayScheduler', () => {
|
||||||
|
describe('scheduleNext', () => {
|
||||||
|
describe('when setting a new timeout', () => {
|
||||||
|
it('sets callback correctly', () => {
|
||||||
|
// arrange
|
||||||
|
const timerStub = new TimeFunctionsStub();
|
||||||
|
const scheduler = new TimeoutDelayScheduler(timerStub);
|
||||||
|
const expectedCallback = () => { /* NO OP */ };
|
||||||
|
// act
|
||||||
|
scheduler.scheduleNext(expectedCallback, 3131);
|
||||||
|
// assert
|
||||||
|
const setTimeoutCalls = timerStub.callHistory.filter((c) => c.methodName === 'setTimeout');
|
||||||
|
expect(setTimeoutCalls).to.have.lengthOf(1);
|
||||||
|
const [actualCallback] = setTimeoutCalls[0].args;
|
||||||
|
expect(actualCallback).toBe(expectedCallback);
|
||||||
|
});
|
||||||
|
it('sets delay correctly', () => {
|
||||||
|
// arrange
|
||||||
|
const timerStub = new TimeFunctionsStub();
|
||||||
|
const scheduler = new TimeoutDelayScheduler(timerStub);
|
||||||
|
const expectedDelay = 100;
|
||||||
|
// act
|
||||||
|
scheduler.scheduleNext(() => {}, expectedDelay);
|
||||||
|
// assert
|
||||||
|
const setTimeoutCalls = timerStub.callHistory.filter((c) => c.methodName === 'setTimeout');
|
||||||
|
expect(setTimeoutCalls).to.have.lengthOf(1);
|
||||||
|
const [,actualDelay] = setTimeoutCalls[0].args;
|
||||||
|
expect(actualDelay).toBe(expectedDelay);
|
||||||
|
});
|
||||||
|
it('does not clear any timeout if none was previously set', () => {
|
||||||
|
// arrange
|
||||||
|
const timerStub = new TimeFunctionsStub();
|
||||||
|
const scheduler = new TimeoutDelayScheduler(timerStub);
|
||||||
|
// act
|
||||||
|
scheduler.scheduleNext(() => {}, 100);
|
||||||
|
// assert
|
||||||
|
const clearTimeoutCalls = timerStub.callHistory.filter((c) => c.methodName === 'clearTimeout');
|
||||||
|
expect(clearTimeoutCalls.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('when rescheduling a timeout', () => {
|
||||||
|
it('clears the previous timeout', () => {
|
||||||
|
// arrange
|
||||||
|
const timerStub = new TimeFunctionsStub();
|
||||||
|
const scheduler = new TimeoutDelayScheduler(timerStub);
|
||||||
|
const idOfFirstSetTimeoutCall = 1;
|
||||||
|
// act
|
||||||
|
scheduler.scheduleNext(() => {}, 100);
|
||||||
|
scheduler.scheduleNext(() => {}, 200);
|
||||||
|
// assert
|
||||||
|
const setTimeoutCalls = timerStub.callHistory.filter((c) => c.methodName === 'setTimeout');
|
||||||
|
expect(setTimeoutCalls.length).toBe(2);
|
||||||
|
const clearTimeoutCalls = timerStub.callHistory.filter((c) => c.methodName === 'clearTimeout');
|
||||||
|
expect(clearTimeoutCalls.length).toBe(1);
|
||||||
|
const [actualId] = clearTimeoutCalls[0].args;
|
||||||
|
expect(actualId).toBe(idOfFirstSetTimeoutCall);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class TimeFunctionsStub
|
||||||
|
extends StubWithObservableMethodCalls<TimeFunctions>
|
||||||
|
implements TimeFunctions {
|
||||||
|
public clearTimeout(id: ReturnType<typeof setTimeout>): void {
|
||||||
|
this.registerMethodCall({
|
||||||
|
methodName: 'clearTimeout',
|
||||||
|
args: [id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public setTimeout(callback: () => void, delayInMs: number): ReturnType<typeof setTimeout> {
|
||||||
|
this.registerMethodCall({
|
||||||
|
methodName: 'setTimeout',
|
||||||
|
args: [callback, delayInMs],
|
||||||
|
});
|
||||||
|
return this.callHistory.filter((c) => c.methodName === 'setTimeout').length as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { WatchSource } from 'vue';
|
||||||
|
import { useGradualNodeRendering } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering';
|
||||||
|
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
|
||||||
|
import { TreeRootStub } from '@tests/unit/shared/Stubs/TreeRootStub';
|
||||||
|
import { UseNodeStateChangeAggregatorStub } from '@tests/unit/shared/Stubs/UseNodeStateChangeAggregatorStub';
|
||||||
|
import { UseCurrentTreeNodesStub } from '@tests/unit/shared/Stubs/UseCurrentTreeNodesStub';
|
||||||
|
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||||
|
import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
|
||||||
|
import { QueryableNodesStub } from '@tests/unit/shared/Stubs/QueryableNodesStub';
|
||||||
|
import { NodeStateChangeEventArgsStub } from '@tests/unit/shared/Stubs/NodeStateChangeEventArgsStub';
|
||||||
|
import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub';
|
||||||
|
import { DelaySchedulerStub } from '@tests/unit/shared/Stubs/DelaySchedulerStub';
|
||||||
|
import { DelayScheduler } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/DelayScheduler';
|
||||||
|
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
|
||||||
|
|
||||||
|
describe('useGradualNodeRendering', () => {
|
||||||
|
it('watches nodes on specified tree', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedWatcher = () => new TreeRootStub();
|
||||||
|
const currentTreeNodesStub = new UseCurrentTreeNodesStub();
|
||||||
|
const builder = new UseGradualNodeRenderingBuilder()
|
||||||
|
.withCurrentTreeNodes(currentTreeNodesStub)
|
||||||
|
.withTreeWatcher(expectedWatcher);
|
||||||
|
// act
|
||||||
|
builder.call();
|
||||||
|
// assert
|
||||||
|
const actualWatcher = currentTreeNodesStub.treeWatcher;
|
||||||
|
expect(actualWatcher).to.equal(expectedWatcher);
|
||||||
|
});
|
||||||
|
describe('shouldRender', () => {
|
||||||
|
describe('on visibility toggle', () => {
|
||||||
|
const scenarios: ReadonlyArray<{
|
||||||
|
readonly description: string;
|
||||||
|
readonly oldVisibilityState: boolean;
|
||||||
|
readonly newVisibilityState: boolean;
|
||||||
|
readonly expectedRenderStatus: boolean;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
description: 'renders node when made visible',
|
||||||
|
oldVisibilityState: false,
|
||||||
|
newVisibilityState: true,
|
||||||
|
expectedRenderStatus: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'does not render node when hidden',
|
||||||
|
oldVisibilityState: true,
|
||||||
|
newVisibilityState: false,
|
||||||
|
expectedRenderStatus: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
scenarios.forEach(({
|
||||||
|
description, newVisibilityState, oldVisibilityState, expectedRenderStatus,
|
||||||
|
}) => {
|
||||||
|
it(description, () => {
|
||||||
|
// arrange
|
||||||
|
const node = createNodeWithVisibility(oldVisibilityState);
|
||||||
|
const nodesStub = new UseCurrentTreeNodesStub()
|
||||||
|
.withQueryableNodes(new QueryableNodesStub().withFlattenedNodes([node]));
|
||||||
|
const aggregatorStub = new UseNodeStateChangeAggregatorStub();
|
||||||
|
const delaySchedulerStub = new DelaySchedulerStub();
|
||||||
|
const builder = new UseGradualNodeRenderingBuilder()
|
||||||
|
.withCurrentTreeNodes(nodesStub)
|
||||||
|
.withChangeAggregator(aggregatorStub)
|
||||||
|
.withDelayScheduler(delaySchedulerStub);
|
||||||
|
const change = new NodeStateChangeEventArgsStub()
|
||||||
|
.withNode(node)
|
||||||
|
.withOldState(new TreeNodeStateDescriptorStub().withVisibility(oldVisibilityState))
|
||||||
|
.withNewState(new TreeNodeStateDescriptorStub().withVisibility(newVisibilityState));
|
||||||
|
// act
|
||||||
|
const strategy = builder.call();
|
||||||
|
aggregatorStub.notifyChange(change);
|
||||||
|
const actualRenderStatus = strategy.shouldRender(node);
|
||||||
|
// assert
|
||||||
|
expect(actualRenderStatus).to.equal(expectedRenderStatus);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('on initial nodes', () => {
|
||||||
|
const scenarios: ReadonlyArray<{
|
||||||
|
readonly description: string;
|
||||||
|
readonly schedulerTicks: number;
|
||||||
|
readonly initialBatchSize: number;
|
||||||
|
readonly subsequentBatchSize: number;
|
||||||
|
readonly nodes: readonly TreeNode[];
|
||||||
|
readonly expectedRenderStatuses: readonly number[],
|
||||||
|
}> = [
|
||||||
|
(() => {
|
||||||
|
const totalNodes = 10;
|
||||||
|
return {
|
||||||
|
description: 'does not render if all nodes are hidden',
|
||||||
|
schedulerTicks: 0,
|
||||||
|
initialBatchSize: 5,
|
||||||
|
subsequentBatchSize: 2,
|
||||||
|
nodes: createNodesWithVisibility(false, totalNodes),
|
||||||
|
expectedRenderStatuses: new Array(totalNodes).fill(false),
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
(() => {
|
||||||
|
const expectedRenderStatuses = [
|
||||||
|
false, false, true, true, false,
|
||||||
|
];
|
||||||
|
const nodes = expectedRenderStatuses.map((status) => createNodeWithVisibility(status));
|
||||||
|
return {
|
||||||
|
description: 'renders only visible nodes',
|
||||||
|
schedulerTicks: 0,
|
||||||
|
initialBatchSize: nodes.length,
|
||||||
|
subsequentBatchSize: 2,
|
||||||
|
nodes,
|
||||||
|
expectedRenderStatuses,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
(() => {
|
||||||
|
const initialBatchSize = 5;
|
||||||
|
return {
|
||||||
|
description: 'renders initial nodes immediately',
|
||||||
|
schedulerTicks: 0,
|
||||||
|
initialBatchSize,
|
||||||
|
subsequentBatchSize: 2,
|
||||||
|
nodes: createNodesWithVisibility(true, initialBatchSize),
|
||||||
|
expectedRenderStatuses: new Array(initialBatchSize).fill(true),
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
(() => {
|
||||||
|
const initialBatchSize = 5;
|
||||||
|
const subsequentBatchSize = 2;
|
||||||
|
const totalNodes = initialBatchSize + subsequentBatchSize * 2;
|
||||||
|
return {
|
||||||
|
description: 'does not render subsequent node batches immediately',
|
||||||
|
schedulerTicks: 0,
|
||||||
|
initialBatchSize,
|
||||||
|
subsequentBatchSize,
|
||||||
|
nodes: createNodesWithVisibility(true, totalNodes),
|
||||||
|
expectedRenderStatuses: [
|
||||||
|
...new Array(initialBatchSize).fill(true),
|
||||||
|
...new Array(totalNodes - initialBatchSize).fill(false),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
(() => {
|
||||||
|
const initialBatchSize = 5;
|
||||||
|
const subsequentBatchSize = 2;
|
||||||
|
const totalNodes = initialBatchSize + subsequentBatchSize * 2;
|
||||||
|
return {
|
||||||
|
description: 'eventually renders next subsequent node batch',
|
||||||
|
schedulerTicks: 1,
|
||||||
|
initialBatchSize,
|
||||||
|
subsequentBatchSize,
|
||||||
|
nodes: createNodesWithVisibility(true, totalNodes),
|
||||||
|
expectedRenderStatuses: [
|
||||||
|
...new Array(initialBatchSize).fill(true),
|
||||||
|
...new Array(subsequentBatchSize).fill(true), // first batch
|
||||||
|
...new Array(subsequentBatchSize).fill(false), // second batch
|
||||||
|
],
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
(() => {
|
||||||
|
const initialBatchSize = 5;
|
||||||
|
const totalSubsequentBatches = 2;
|
||||||
|
const subsequentBatchSize = 2;
|
||||||
|
const totalNodes = initialBatchSize + subsequentBatchSize * totalSubsequentBatches;
|
||||||
|
return {
|
||||||
|
description: 'eventually renders all subsequent node batches',
|
||||||
|
schedulerTicks: subsequentBatchSize,
|
||||||
|
initialBatchSize,
|
||||||
|
subsequentBatchSize,
|
||||||
|
nodes: createNodesWithVisibility(true, totalNodes),
|
||||||
|
expectedRenderStatuses: new Array(totalNodes).fill(true),
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
];
|
||||||
|
scenarios.forEach(({
|
||||||
|
description, nodes, schedulerTicks, initialBatchSize,
|
||||||
|
subsequentBatchSize, expectedRenderStatuses,
|
||||||
|
}) => {
|
||||||
|
it(description, () => {
|
||||||
|
// arrange
|
||||||
|
const delaySchedulerStub = new DelaySchedulerStub();
|
||||||
|
const nodesStub = new UseCurrentTreeNodesStub()
|
||||||
|
.withQueryableNodes(new QueryableNodesStub().withFlattenedNodes(nodes));
|
||||||
|
const builder = new UseGradualNodeRenderingBuilder()
|
||||||
|
.withCurrentTreeNodes(nodesStub)
|
||||||
|
.withInitialBatchSize(initialBatchSize)
|
||||||
|
.withSubsequentBatchSize(subsequentBatchSize)
|
||||||
|
.withDelayScheduler(delaySchedulerStub);
|
||||||
|
// act
|
||||||
|
const strategy = builder.call();
|
||||||
|
Array.from({ length: schedulerTicks }).forEach(
|
||||||
|
() => delaySchedulerStub.runNextScheduled(),
|
||||||
|
);
|
||||||
|
const actualRenderStatuses = nodes.map((node) => strategy.shouldRender(node));
|
||||||
|
// expect
|
||||||
|
expect(actualRenderStatuses).to.deep.equal(expectedRenderStatuses);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('skips scheduling when no nodes to render', () => {
|
||||||
|
// arrange
|
||||||
|
const nodes = [];
|
||||||
|
const nodesStub = new UseCurrentTreeNodesStub()
|
||||||
|
.withQueryableNodes(new QueryableNodesStub().withFlattenedNodes(nodes));
|
||||||
|
const delaySchedulerStub = new DelaySchedulerStub();
|
||||||
|
const builder = new UseGradualNodeRenderingBuilder()
|
||||||
|
.withCurrentTreeNodes(nodesStub)
|
||||||
|
.withDelayScheduler(delaySchedulerStub);
|
||||||
|
// act
|
||||||
|
builder.call();
|
||||||
|
// assert
|
||||||
|
expect(delaySchedulerStub.nextCallback).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createNodesWithVisibility(
|
||||||
|
isVisible: boolean,
|
||||||
|
count: number,
|
||||||
|
): readonly TreeNodeStub[] {
|
||||||
|
return Array.from({ length: count })
|
||||||
|
.map(() => createNodeWithVisibility(isVisible));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNodeWithVisibility(
|
||||||
|
isVisible: boolean,
|
||||||
|
): TreeNodeStub {
|
||||||
|
return new TreeNodeStub()
|
||||||
|
.withState(
|
||||||
|
new TreeNodeStateAccessStub().withCurrentVisibility(isVisible),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class UseGradualNodeRenderingBuilder {
|
||||||
|
private changeAggregator = new UseNodeStateChangeAggregatorStub();
|
||||||
|
|
||||||
|
private treeWatcher: WatchSource<TreeRoot | undefined> = () => new TreeRootStub();
|
||||||
|
|
||||||
|
private currentTreeNodes = new UseCurrentTreeNodesStub();
|
||||||
|
|
||||||
|
private delayScheduler: DelayScheduler = new DelaySchedulerStub();
|
||||||
|
|
||||||
|
private initialBatchSize = 5;
|
||||||
|
|
||||||
|
private subsequentBatchSize = 3;
|
||||||
|
|
||||||
|
public withChangeAggregator(changeAggregator: UseNodeStateChangeAggregatorStub): this {
|
||||||
|
this.changeAggregator = changeAggregator;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withCurrentTreeNodes(treeNodes: UseCurrentTreeNodesStub): this {
|
||||||
|
this.currentTreeNodes = treeNodes;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withTreeWatcher(treeWatcher: WatchSource<TreeRoot | undefined>): this {
|
||||||
|
this.treeWatcher = treeWatcher;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withDelayScheduler(delayScheduler: DelayScheduler): this {
|
||||||
|
this.delayScheduler = delayScheduler;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withInitialBatchSize(initialBatchSize: number): this {
|
||||||
|
this.initialBatchSize = initialBatchSize;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withSubsequentBatchSize(subsequentBatchSize: number): this {
|
||||||
|
this.subsequentBatchSize = subsequentBatchSize;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public call(): ReturnType<typeof useGradualNodeRendering> {
|
||||||
|
return useGradualNodeRendering(
|
||||||
|
this.treeWatcher,
|
||||||
|
this.changeAggregator.get(),
|
||||||
|
this.currentTreeNodes.get(),
|
||||||
|
this.delayScheduler,
|
||||||
|
this.initialBatchSize,
|
||||||
|
this.subsequentBatchSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,6 @@ describe('SingleNodeCollectionFocusManager', () => {
|
|||||||
function getNodeWithFocusState(isFocused: boolean): TreeNodeStub {
|
function getNodeWithFocusState(isFocused: boolean): TreeNodeStub {
|
||||||
return new TreeNodeStub()
|
return new TreeNodeStub()
|
||||||
.withState(new TreeNodeStateAccessStub().withCurrent(
|
.withState(new TreeNodeStateAccessStub().withCurrent(
|
||||||
new TreeNodeStateDescriptorStub().withFocusState(isFocused),
|
new TreeNodeStateDescriptorStub().withFocus(isFocused),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { WatchSource } from 'vue';
|
||||||
|
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
|
||||||
|
import { useAutoUpdateChildrenCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateChildrenCheckState';
|
||||||
|
import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
|
||||||
|
import { UseNodeStateChangeAggregatorStub } from '@tests/unit/shared/Stubs/UseNodeStateChangeAggregatorStub';
|
||||||
|
import { getAbsentObjectTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||||
|
import { TreeRootStub } from '@tests/unit/shared/Stubs/TreeRootStub';
|
||||||
|
import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub';
|
||||||
|
import { TreeNodeStateAccessStub, createAccessStubsFromCheckStates } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
|
||||||
|
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
|
||||||
|
import { createChangeEvent } from '@tests/unit/shared/Stubs/NodeStateChangeEventArgsStub';
|
||||||
|
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||||
|
|
||||||
|
describe('useAutoUpdateChildrenCheckState', () => {
|
||||||
|
it('registers change handler', () => {
|
||||||
|
// arrange
|
||||||
|
const aggregatorStub = new UseNodeStateChangeAggregatorStub();
|
||||||
|
const builder = new UseAutoUpdateChildrenCheckStateBuilder()
|
||||||
|
.withChangeAggregator(aggregatorStub);
|
||||||
|
// act
|
||||||
|
builder.call();
|
||||||
|
// assert
|
||||||
|
expect(aggregatorStub.callback).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('aggregate changes on specified tree', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedWatcher = () => new TreeRootStub();
|
||||||
|
const aggregatorStub = new UseNodeStateChangeAggregatorStub();
|
||||||
|
const builder = new UseAutoUpdateChildrenCheckStateBuilder()
|
||||||
|
.withChangeAggregator(aggregatorStub)
|
||||||
|
.withTreeWatcher(expectedWatcher);
|
||||||
|
// act
|
||||||
|
builder.call();
|
||||||
|
// assert
|
||||||
|
const actualWatcher = aggregatorStub.treeWatcher;
|
||||||
|
expect(actualWatcher).to.equal(expectedWatcher);
|
||||||
|
});
|
||||||
|
describe('skips event handling', () => {
|
||||||
|
const scenarios: ReadonlyArray<{
|
||||||
|
readonly description: string,
|
||||||
|
readonly oldState: TreeNodeCheckState,
|
||||||
|
readonly newState: TreeNodeCheckState,
|
||||||
|
readonly childrenStates: readonly TreeNodeStateAccessStub[],
|
||||||
|
readonly isLeafNode: boolean,
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
description: 'remains same: unchecked → unchecked',
|
||||||
|
oldState: TreeNodeCheckState.Unchecked,
|
||||||
|
newState: TreeNodeCheckState.Unchecked,
|
||||||
|
childrenStates: getAllPossibleCheckStates(),
|
||||||
|
isLeafNode: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'remains same: checked → checked',
|
||||||
|
oldState: TreeNodeCheckState.Checked,
|
||||||
|
newState: TreeNodeCheckState.Checked,
|
||||||
|
childrenStates: getAllPossibleCheckStates(),
|
||||||
|
isLeafNode: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'to indeterminate: checked → indeterminate',
|
||||||
|
oldState: TreeNodeCheckState.Checked,
|
||||||
|
newState: TreeNodeCheckState.Indeterminate,
|
||||||
|
childrenStates: getAllPossibleCheckStates(),
|
||||||
|
isLeafNode: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'to indeterminate: unchecked → indeterminate',
|
||||||
|
oldState: TreeNodeCheckState.Unchecked,
|
||||||
|
newState: TreeNodeCheckState.Indeterminate,
|
||||||
|
childrenStates: getAllPossibleCheckStates(),
|
||||||
|
isLeafNode: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'parent is leaf node: checked → unchecked',
|
||||||
|
oldState: TreeNodeCheckState.Checked,
|
||||||
|
newState: TreeNodeCheckState.Indeterminate,
|
||||||
|
childrenStates: getAllPossibleCheckStates(),
|
||||||
|
isLeafNode: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'child node\'s state remains unchanged: unchecked → checked',
|
||||||
|
oldState: TreeNodeCheckState.Unchecked,
|
||||||
|
newState: TreeNodeCheckState.Checked,
|
||||||
|
childrenStates: createAccessStubsFromCheckStates([
|
||||||
|
TreeNodeCheckState.Checked,
|
||||||
|
TreeNodeCheckState.Checked,
|
||||||
|
]),
|
||||||
|
isLeafNode: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'child node\'s state remains unchanged: checked → unchecked',
|
||||||
|
oldState: TreeNodeCheckState.Checked,
|
||||||
|
newState: TreeNodeCheckState.Unchecked,
|
||||||
|
childrenStates: createAccessStubsFromCheckStates([
|
||||||
|
TreeNodeCheckState.Unchecked,
|
||||||
|
TreeNodeCheckState.Unchecked,
|
||||||
|
]),
|
||||||
|
isLeafNode: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
scenarios.forEach(({
|
||||||
|
description, newState, oldState, childrenStates, isLeafNode,
|
||||||
|
}) => {
|
||||||
|
it(description, () => {
|
||||||
|
// arrange
|
||||||
|
const aggregatorStub = new UseNodeStateChangeAggregatorStub();
|
||||||
|
const builder = new UseAutoUpdateChildrenCheckStateBuilder()
|
||||||
|
.withChangeAggregator(aggregatorStub);
|
||||||
|
const changeEvent = createChangeEvent({
|
||||||
|
oldState: new TreeNodeStateDescriptorStub().withCheckState(oldState),
|
||||||
|
newState: new TreeNodeStateDescriptorStub().withCheckState(newState),
|
||||||
|
hierarchyBuilder: (hierarchy) => hierarchy
|
||||||
|
.withIsLeafNode(isLeafNode)
|
||||||
|
.withChildren(TreeNodeStub.fromStates(childrenStates)),
|
||||||
|
});
|
||||||
|
// act
|
||||||
|
builder.call();
|
||||||
|
aggregatorStub.notifyChange(changeEvent);
|
||||||
|
// assert
|
||||||
|
const changedStates = childrenStates
|
||||||
|
.filter((stub) => stub.isStateModificationRequested);
|
||||||
|
expect(changedStates).to.have.lengthOf(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('updates children as expected', () => {
|
||||||
|
const scenarios: ReadonlyArray<{
|
||||||
|
readonly description: string;
|
||||||
|
readonly oldState?: TreeNodeStateDescriptor;
|
||||||
|
readonly newState: TreeNodeStateDescriptor;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
description: 'unchecked → checked',
|
||||||
|
oldState: new TreeNodeStateDescriptorStub().withCheckState(TreeNodeCheckState.Unchecked),
|
||||||
|
newState: new TreeNodeStateDescriptorStub().withCheckState(TreeNodeCheckState.Checked),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'checked → unchecked',
|
||||||
|
oldState: new TreeNodeStateDescriptorStub().withCheckState(TreeNodeCheckState.Checked),
|
||||||
|
newState: new TreeNodeStateDescriptorStub().withCheckState(TreeNodeCheckState.Unchecked),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'indeterminate → unchecked',
|
||||||
|
oldState: new TreeNodeStateDescriptorStub()
|
||||||
|
.withCheckState(TreeNodeCheckState.Indeterminate),
|
||||||
|
newState: new TreeNodeStateDescriptorStub().withCheckState(TreeNodeCheckState.Unchecked),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'indeterminate → checked',
|
||||||
|
oldState: new TreeNodeStateDescriptorStub()
|
||||||
|
.withCheckState(TreeNodeCheckState.Indeterminate),
|
||||||
|
newState: new TreeNodeStateDescriptorStub().withCheckState(TreeNodeCheckState.Checked),
|
||||||
|
},
|
||||||
|
...getAbsentObjectTestCases().map((testCase) => ({
|
||||||
|
description: `absent old state: "${testCase.valueName}"`,
|
||||||
|
oldState: testCase.absentValue,
|
||||||
|
newState: new TreeNodeStateDescriptorStub().withCheckState(TreeNodeCheckState.Unchecked),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
scenarios.forEach(({ description, newState, oldState }) => {
|
||||||
|
it(description, () => {
|
||||||
|
// arrange
|
||||||
|
const aggregatorStub = new UseNodeStateChangeAggregatorStub();
|
||||||
|
const childrenStates = getAllPossibleCheckStates();
|
||||||
|
const expectedChildrenStates = childrenStates.map(() => newState.checkState);
|
||||||
|
const builder = new UseAutoUpdateChildrenCheckStateBuilder()
|
||||||
|
.withChangeAggregator(aggregatorStub);
|
||||||
|
const changeEvent = createChangeEvent({
|
||||||
|
oldState,
|
||||||
|
newState,
|
||||||
|
hierarchyBuilder: (hierarchy) => hierarchy
|
||||||
|
.withIsLeafNode(false)
|
||||||
|
.withChildren(TreeNodeStub.fromStates(childrenStates)),
|
||||||
|
});
|
||||||
|
// act
|
||||||
|
builder.call();
|
||||||
|
aggregatorStub.notifyChange(changeEvent);
|
||||||
|
// assert
|
||||||
|
const actualStates = childrenStates.map((state) => state.current.checkState);
|
||||||
|
expect(actualStates).to.have.lengthOf(expectedChildrenStates.length);
|
||||||
|
expect(actualStates).to.have.members(expectedChildrenStates);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getAllPossibleCheckStates() {
|
||||||
|
return createAccessStubsFromCheckStates([
|
||||||
|
TreeNodeCheckState.Checked,
|
||||||
|
TreeNodeCheckState.Unchecked,
|
||||||
|
TreeNodeCheckState.Indeterminate,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
class UseAutoUpdateChildrenCheckStateBuilder {
|
||||||
|
private changeAggregator = new UseNodeStateChangeAggregatorStub();
|
||||||
|
|
||||||
|
private treeWatcher: WatchSource<TreeRoot | undefined> = () => new TreeRootStub();
|
||||||
|
|
||||||
|
public withChangeAggregator(changeAggregator: UseNodeStateChangeAggregatorStub): this {
|
||||||
|
this.changeAggregator = changeAggregator;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withTreeWatcher(treeWatcher: WatchSource<TreeRoot | undefined>): this {
|
||||||
|
this.treeWatcher = treeWatcher;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public call(): ReturnType<typeof useAutoUpdateChildrenCheckState> {
|
||||||
|
return useAutoUpdateChildrenCheckState(
|
||||||
|
this.treeWatcher,
|
||||||
|
this.changeAggregator.get(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { WatchSource } from 'vue';
|
||||||
|
import { UseNodeStateChangeAggregatorStub } from '@tests/unit/shared/Stubs/UseNodeStateChangeAggregatorStub';
|
||||||
|
import { TreeRootStub } from '@tests/unit/shared/Stubs/TreeRootStub';
|
||||||
|
import { useAutoUpdateParentCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateParentCheckState';
|
||||||
|
import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub';
|
||||||
|
import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
|
||||||
|
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||||
|
import { HierarchyAccessStub } from '@tests/unit/shared/Stubs/HierarchyAccessStub';
|
||||||
|
import { NodeStateChangeEventArgsStub, createChangeEvent } from '@tests/unit/shared/Stubs/NodeStateChangeEventArgsStub';
|
||||||
|
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
|
||||||
|
import { TreeNodeStateAccessStub, createAccessStubsFromCheckStates } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
|
||||||
|
|
||||||
|
describe('useAutoUpdateParentCheckState', () => {
|
||||||
|
it('registers change handler', () => {
|
||||||
|
// arrange
|
||||||
|
const aggregatorStub = new UseNodeStateChangeAggregatorStub();
|
||||||
|
const builder = new UseAutoUpdateParentCheckStateBuilder()
|
||||||
|
.withChangeAggregator(aggregatorStub);
|
||||||
|
// act
|
||||||
|
builder.call();
|
||||||
|
// assert
|
||||||
|
expect(aggregatorStub.callback).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('aggregate changes on specified tree', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedWatcher = () => new TreeRootStub();
|
||||||
|
const aggregatorStub = new UseNodeStateChangeAggregatorStub();
|
||||||
|
const builder = new UseAutoUpdateParentCheckStateBuilder()
|
||||||
|
.withChangeAggregator(aggregatorStub)
|
||||||
|
.withTreeWatcher(expectedWatcher);
|
||||||
|
// act
|
||||||
|
builder.call();
|
||||||
|
// assert
|
||||||
|
const actualWatcher = aggregatorStub.treeWatcher;
|
||||||
|
expect(actualWatcher).to.equal(expectedWatcher);
|
||||||
|
});
|
||||||
|
it('does not throw if node has no parent', () => {
|
||||||
|
// arrange
|
||||||
|
const aggregatorStub = new UseNodeStateChangeAggregatorStub();
|
||||||
|
const changeEvent = new NodeStateChangeEventArgsStub()
|
||||||
|
.withNode(
|
||||||
|
new TreeNodeStub().withHierarchy(
|
||||||
|
new HierarchyAccessStub().withParent(undefined),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const builder = new UseAutoUpdateParentCheckStateBuilder()
|
||||||
|
.withChangeAggregator(aggregatorStub);
|
||||||
|
// act
|
||||||
|
builder.call();
|
||||||
|
const act = () => aggregatorStub.notifyChange(changeEvent);
|
||||||
|
// assert
|
||||||
|
expect(act).to.not.throw();
|
||||||
|
});
|
||||||
|
describe('skips event handling', () => {
|
||||||
|
const scenarios: ReadonlyArray<{
|
||||||
|
readonly description: string,
|
||||||
|
readonly parentState: TreeNodeCheckState,
|
||||||
|
readonly newChildState: TreeNodeCheckState,
|
||||||
|
readonly oldChildState: TreeNodeCheckState,
|
||||||
|
readonly parentNodeChildrenStates: readonly TreeNodeCheckState[],
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
description: 'check state remains the same',
|
||||||
|
parentState: TreeNodeCheckState.Checked,
|
||||||
|
newChildState: TreeNodeCheckState.Checked,
|
||||||
|
oldChildState: TreeNodeCheckState.Checked,
|
||||||
|
parentNodeChildrenStates: [TreeNodeCheckState.Checked], // these states do not matter
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'if parent node has same target state as children: Unchecked',
|
||||||
|
parentState: TreeNodeCheckState.Unchecked,
|
||||||
|
newChildState: TreeNodeCheckState.Unchecked,
|
||||||
|
oldChildState: TreeNodeCheckState.Checked,
|
||||||
|
parentNodeChildrenStates: [
|
||||||
|
TreeNodeCheckState.Unchecked,
|
||||||
|
TreeNodeCheckState.Unchecked,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'if parent node has same target state as children: Checked',
|
||||||
|
parentState: TreeNodeCheckState.Checked,
|
||||||
|
newChildState: TreeNodeCheckState.Checked,
|
||||||
|
oldChildState: TreeNodeCheckState.Unchecked,
|
||||||
|
parentNodeChildrenStates: [
|
||||||
|
TreeNodeCheckState.Checked,
|
||||||
|
TreeNodeCheckState.Checked,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'if parent node has same target state as children: Indeterminate',
|
||||||
|
parentState: TreeNodeCheckState.Indeterminate,
|
||||||
|
newChildState: TreeNodeCheckState.Indeterminate,
|
||||||
|
oldChildState: TreeNodeCheckState.Unchecked,
|
||||||
|
parentNodeChildrenStates: [
|
||||||
|
TreeNodeCheckState.Indeterminate,
|
||||||
|
TreeNodeCheckState.Indeterminate,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
scenarios.forEach(({
|
||||||
|
description, newChildState, oldChildState, parentState, parentNodeChildrenStates,
|
||||||
|
}) => {
|
||||||
|
it(description, () => {
|
||||||
|
// arrange
|
||||||
|
const parentStateStub = new TreeNodeStateAccessStub()
|
||||||
|
.withCurrentCheckState(parentState);
|
||||||
|
const aggregatorStub = new UseNodeStateChangeAggregatorStub();
|
||||||
|
const builder = new UseAutoUpdateParentCheckStateBuilder()
|
||||||
|
.withChangeAggregator(aggregatorStub);
|
||||||
|
const changeEvent = createChangeEvent({
|
||||||
|
oldState: new TreeNodeStateDescriptorStub().withCheckState(oldChildState),
|
||||||
|
newState: new TreeNodeStateDescriptorStub().withCheckState(newChildState),
|
||||||
|
hierarchyBuilder: (hierarchy) => hierarchy.withParent(
|
||||||
|
new TreeNodeStub()
|
||||||
|
.withState(parentStateStub)
|
||||||
|
.withHierarchy(
|
||||||
|
new HierarchyAccessStub().withChildren(TreeNodeStub.fromStates(
|
||||||
|
createAccessStubsFromCheckStates(parentNodeChildrenStates),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
// act
|
||||||
|
builder.call();
|
||||||
|
aggregatorStub.notifyChange(changeEvent);
|
||||||
|
// assert
|
||||||
|
expect(parentStateStub.isStateModificationRequested).to.equal(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('updates parent check state based on children', () => {
|
||||||
|
const scenarios: ReadonlyArray<{
|
||||||
|
readonly description: string;
|
||||||
|
readonly parentNodeChildrenStates: readonly TreeNodeCheckState[];
|
||||||
|
readonly expectedParentState: TreeNodeCheckState;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
description: 'all children checked → parent checked',
|
||||||
|
parentNodeChildrenStates: [TreeNodeCheckState.Checked, TreeNodeCheckState.Checked],
|
||||||
|
expectedParentState: TreeNodeCheckState.Checked,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'all children unchecked → parent unchecked',
|
||||||
|
parentNodeChildrenStates: [TreeNodeCheckState.Unchecked, TreeNodeCheckState.Unchecked],
|
||||||
|
expectedParentState: TreeNodeCheckState.Unchecked,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'mixed children states → parent indeterminate',
|
||||||
|
parentNodeChildrenStates: [TreeNodeCheckState.Checked, TreeNodeCheckState.Unchecked],
|
||||||
|
expectedParentState: TreeNodeCheckState.Indeterminate,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
scenarios.forEach(({ description, parentNodeChildrenStates, expectedParentState }) => {
|
||||||
|
it(description, () => {
|
||||||
|
// arrange
|
||||||
|
const aggregatorStub = new UseNodeStateChangeAggregatorStub();
|
||||||
|
const parentStateStub = new TreeNodeStateAccessStub()
|
||||||
|
.withCurrentCheckState(TreeNodeCheckState.Unchecked);
|
||||||
|
const changeEvent = createChangeEvent({
|
||||||
|
oldState: new TreeNodeStateDescriptorStub().withCheckState(TreeNodeCheckState.Unchecked),
|
||||||
|
newState: new TreeNodeStateDescriptorStub().withCheckState(TreeNodeCheckState.Checked),
|
||||||
|
hierarchyBuilder: (hierarchy) => hierarchy.withParent(
|
||||||
|
new TreeNodeStub()
|
||||||
|
.withState(parentStateStub)
|
||||||
|
.withHierarchy(
|
||||||
|
new HierarchyAccessStub().withChildren(TreeNodeStub.fromStates(
|
||||||
|
createAccessStubsFromCheckStates(parentNodeChildrenStates),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const builder = new UseAutoUpdateParentCheckStateBuilder()
|
||||||
|
.withChangeAggregator(aggregatorStub);
|
||||||
|
// act
|
||||||
|
builder.call();
|
||||||
|
aggregatorStub.notifyChange(changeEvent);
|
||||||
|
// assert
|
||||||
|
expect(parentStateStub.current.checkState).to.equal(expectedParentState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class UseAutoUpdateParentCheckStateBuilder {
|
||||||
|
private changeAggregator = new UseNodeStateChangeAggregatorStub();
|
||||||
|
|
||||||
|
private treeWatcher: WatchSource<TreeRoot | undefined> = () => new TreeRootStub();
|
||||||
|
|
||||||
|
public withChangeAggregator(changeAggregator: UseNodeStateChangeAggregatorStub): this {
|
||||||
|
this.changeAggregator = changeAggregator;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withTreeWatcher(treeWatcher: WatchSource<TreeRoot | undefined>): this {
|
||||||
|
this.treeWatcher = treeWatcher;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public call(): ReturnType<typeof useAutoUpdateParentCheckState> {
|
||||||
|
return useAutoUpdateParentCheckState(
|
||||||
|
this.treeWatcher,
|
||||||
|
this.changeAggregator.get(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { WatchSource, defineComponent, nextTick } from 'vue';
|
||||||
|
import { shallowMount } from '@vue/test-utils';
|
||||||
|
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
|
||||||
|
import { useCurrentTreeNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes';
|
||||||
|
import { NodeStateChangeEventArgs, NodeStateChangeEventCallback, useNodeStateChangeAggregator } from '@/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator';
|
||||||
|
import { TreeRootStub } from '@tests/unit/shared/Stubs/TreeRootStub';
|
||||||
|
import { UseCurrentTreeNodesStub } from '@tests/unit/shared/Stubs/UseCurrentTreeNodesStub';
|
||||||
|
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||||
|
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
|
||||||
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||||
|
import { QueryableNodesStub } from '@tests/unit/shared/Stubs/QueryableNodesStub';
|
||||||
|
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
|
||||||
|
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
|
||||||
|
import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub';
|
||||||
|
import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
|
||||||
|
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
|
||||||
|
import { FunctionKeys } from '@/TypeHelpers';
|
||||||
|
|
||||||
|
describe('useNodeStateChangeAggregator', () => {
|
||||||
|
it('watches nodes on specified tree', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedWatcher = () => new TreeRootStub();
|
||||||
|
const currentTreeNodesStub = new UseCurrentTreeNodesStub();
|
||||||
|
const builder = new UseNodeStateChangeAggregatorBuilder()
|
||||||
|
.withCurrentTreeNodes(currentTreeNodesStub.get())
|
||||||
|
.withTreeWatcher(expectedWatcher);
|
||||||
|
// act
|
||||||
|
builder.mountWrapperComponent();
|
||||||
|
// assert
|
||||||
|
const actualWatcher = currentTreeNodesStub.treeWatcher;
|
||||||
|
expect(actualWatcher).to.equal(expectedWatcher);
|
||||||
|
});
|
||||||
|
describe('onNodeStateChange', () => {
|
||||||
|
describe('throws if callback is absent', () => {
|
||||||
|
itEachAbsentObjectValue((absentValue) => {
|
||||||
|
// arrange
|
||||||
|
const expectedError = 'missing callback';
|
||||||
|
const { returnObject } = new UseNodeStateChangeAggregatorBuilder()
|
||||||
|
.mountWrapperComponent();
|
||||||
|
// act
|
||||||
|
const act = () => returnObject.onNodeStateChange(absentValue);
|
||||||
|
// assert
|
||||||
|
expect(act).to.throw(expectedError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('notifies current node states', () => {
|
||||||
|
const scenarios: ReadonlyArray<{
|
||||||
|
readonly description: string;
|
||||||
|
readonly expectedNodes: readonly TreeNode[];
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
description: 'given single node',
|
||||||
|
expectedNodes: [
|
||||||
|
new TreeNodeStub().withId('expected-single-node'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'given multiple nodes',
|
||||||
|
expectedNodes: [
|
||||||
|
new TreeNodeStub().withId('expected-first-node'),
|
||||||
|
new TreeNodeStub().withId('expected-second-node'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
scenarios.forEach(({
|
||||||
|
description, expectedNodes,
|
||||||
|
}) => {
|
||||||
|
describe('initially', () => {
|
||||||
|
it(description, async () => {
|
||||||
|
// arrange
|
||||||
|
const nodesStub = new UseCurrentTreeNodesStub()
|
||||||
|
.withQueryableNodes(createFlatCollection(expectedNodes));
|
||||||
|
const { returnObject } = new UseNodeStateChangeAggregatorBuilder()
|
||||||
|
.withCurrentTreeNodes(nodesStub.get())
|
||||||
|
.mountWrapperComponent();
|
||||||
|
const { callback, calledArgs } = createSpyingCallback();
|
||||||
|
// act
|
||||||
|
returnObject.onNodeStateChange(callback);
|
||||||
|
await nextTick();
|
||||||
|
// assert
|
||||||
|
assertCurrentNodeCalls({
|
||||||
|
actualArgs: calledArgs,
|
||||||
|
expectedNodes,
|
||||||
|
expectedNewStates: expectedNodes.map((n) => n.state.current),
|
||||||
|
expectedOldStates: new Array(expectedNodes.length).fill(undefined),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('when the tree changes', () => {
|
||||||
|
it(description, async () => {
|
||||||
|
// arrange
|
||||||
|
const nodesStub = new UseCurrentTreeNodesStub();
|
||||||
|
const { returnObject } = new UseNodeStateChangeAggregatorBuilder()
|
||||||
|
.withCurrentTreeNodes(nodesStub.get())
|
||||||
|
.mountWrapperComponent();
|
||||||
|
const { callback, calledArgs } = createSpyingCallback();
|
||||||
|
// act
|
||||||
|
returnObject.onNodeStateChange(callback);
|
||||||
|
calledArgs.length = 0;
|
||||||
|
nodesStub.triggerNewNodes(createFlatCollection(expectedNodes));
|
||||||
|
await nextTick();
|
||||||
|
// assert
|
||||||
|
assertCurrentNodeCalls({
|
||||||
|
actualArgs: calledArgs,
|
||||||
|
expectedNodes,
|
||||||
|
expectedNewStates: expectedNodes.map((n) => n.state.current),
|
||||||
|
expectedOldStates: new Array(expectedNodes.length).fill(undefined),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('when the callback changes', () => {
|
||||||
|
it(description, async () => {
|
||||||
|
// arrange
|
||||||
|
const nodesStub = new UseCurrentTreeNodesStub()
|
||||||
|
.withQueryableNodes(createFlatCollection(expectedNodes));
|
||||||
|
const { returnObject } = new UseNodeStateChangeAggregatorBuilder()
|
||||||
|
.withCurrentTreeNodes(nodesStub.get())
|
||||||
|
.mountWrapperComponent();
|
||||||
|
const { callback, calledArgs } = createSpyingCallback();
|
||||||
|
// act
|
||||||
|
returnObject.onNodeStateChange(() => { /* NOOP */ });
|
||||||
|
await nextTick();
|
||||||
|
returnObject.onNodeStateChange(callback);
|
||||||
|
await nextTick();
|
||||||
|
// assert
|
||||||
|
assertCurrentNodeCalls({
|
||||||
|
actualArgs: calledArgs,
|
||||||
|
expectedNodes,
|
||||||
|
expectedNewStates: expectedNodes.map((n) => n.state.current),
|
||||||
|
expectedOldStates: new Array(expectedNodes.length).fill(undefined),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('notifies future node states', () => {
|
||||||
|
const scenarios: ReadonlyArray<{
|
||||||
|
readonly description: string;
|
||||||
|
readonly initialNodes: readonly TreeNode[];
|
||||||
|
readonly changedNode: TreeNodeStub;
|
||||||
|
readonly expectedOldState: TreeNodeStateDescriptor;
|
||||||
|
readonly expectedNewState: TreeNodeStateDescriptor;
|
||||||
|
}> = [
|
||||||
|
(() => {
|
||||||
|
const changedNode = new TreeNodeStub().withId('expected-single-node');
|
||||||
|
return {
|
||||||
|
description: 'given single node state change',
|
||||||
|
initialNodes: [changedNode],
|
||||||
|
changedNode,
|
||||||
|
expectedOldState: new TreeNodeStateDescriptorStub().withFocus(false),
|
||||||
|
expectedNewState: new TreeNodeStateDescriptorStub().withFocus(true),
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
(() => {
|
||||||
|
const changedNode = new TreeNodeStub().withId('changed-second-node');
|
||||||
|
return {
|
||||||
|
description: 'given multiple nodes with a state change in one of them',
|
||||||
|
initialNodes: [
|
||||||
|
new TreeNodeStub().withId('unchanged-first-node'),
|
||||||
|
changedNode,
|
||||||
|
],
|
||||||
|
changedNode,
|
||||||
|
expectedOldState: new TreeNodeStateDescriptorStub().withFocus(false),
|
||||||
|
expectedNewState: new TreeNodeStateDescriptorStub().withFocus(true),
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
];
|
||||||
|
|
||||||
|
scenarios.forEach(({
|
||||||
|
description, initialNodes, changedNode, expectedOldState, expectedNewState,
|
||||||
|
}) => {
|
||||||
|
describe('when the state change event is triggered', () => {
|
||||||
|
it(description, async () => {
|
||||||
|
// arrange
|
||||||
|
const nodesStub = new UseCurrentTreeNodesStub()
|
||||||
|
.withQueryableNodes(createFlatCollection(initialNodes));
|
||||||
|
const nodeState = new TreeNodeStateAccessStub();
|
||||||
|
changedNode.withState(nodeState);
|
||||||
|
const { returnObject } = new UseNodeStateChangeAggregatorBuilder()
|
||||||
|
.withCurrentTreeNodes(nodesStub.get())
|
||||||
|
.mountWrapperComponent();
|
||||||
|
const { callback, calledArgs } = createSpyingCallback();
|
||||||
|
returnObject.onNodeStateChange(callback);
|
||||||
|
// act
|
||||||
|
await nextTick();
|
||||||
|
calledArgs.length = 0;
|
||||||
|
nodeState.triggerStateChangedEvent({
|
||||||
|
oldState: expectedOldState,
|
||||||
|
newState: expectedNewState,
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
// assert
|
||||||
|
assertCurrentNodeCalls({
|
||||||
|
actualArgs: calledArgs,
|
||||||
|
expectedNodes: [changedNode],
|
||||||
|
expectedNewStates: [expectedNewState],
|
||||||
|
expectedOldStates: [expectedOldState],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('unsubscribes correctly', () => {
|
||||||
|
const scenarios: ReadonlyArray<{
|
||||||
|
readonly description: string;
|
||||||
|
readonly newNodes: readonly TreeNode[];
|
||||||
|
readonly expectedMethodName: FunctionKeys<IEventSubscriptionCollection>;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
description: 'unsubscribe and re-register events when nodes change',
|
||||||
|
newNodes: [new TreeNodeStub().withId('subsequent-node')],
|
||||||
|
expectedMethodName: 'unsubscribeAllAndRegister',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'unsubscribes all when nodes change to empty',
|
||||||
|
newNodes: [],
|
||||||
|
expectedMethodName: 'unsubscribeAll',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
scenarios.forEach(({ description, expectedMethodName, newNodes }) => {
|
||||||
|
it(description, async () => {
|
||||||
|
// arrange
|
||||||
|
const initialNodes = [new TreeNodeStub().withId('initial-node')];
|
||||||
|
const nodesStub = new UseCurrentTreeNodesStub()
|
||||||
|
.withQueryableNodes(createFlatCollection(initialNodes));
|
||||||
|
const eventsStub = new UseAutoUnsubscribedEventsStub();
|
||||||
|
const { returnObject } = new UseNodeStateChangeAggregatorBuilder()
|
||||||
|
.withCurrentTreeNodes(nodesStub.get())
|
||||||
|
.withEventsStub(eventsStub)
|
||||||
|
.mountWrapperComponent();
|
||||||
|
// act
|
||||||
|
returnObject.onNodeStateChange(() => { /* NOOP */ });
|
||||||
|
await nextTick();
|
||||||
|
eventsStub.events.callHistory.length = 0;
|
||||||
|
nodesStub.triggerNewNodes(createFlatCollection(newNodes));
|
||||||
|
await nextTick();
|
||||||
|
// assert
|
||||||
|
const calls = eventsStub.events.callHistory;
|
||||||
|
expect(eventsStub.events.callHistory).has.lengthOf(1, calls.map((call) => call.methodName).join(', '));
|
||||||
|
const actualMethodName = calls[0].methodName;
|
||||||
|
expect(actualMethodName).to.equal(expectedMethodName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createSpyingCallback() {
|
||||||
|
const calledArgs = new Array<NodeStateChangeEventArgs>();
|
||||||
|
const callback: NodeStateChangeEventCallback = (args: NodeStateChangeEventArgs) => {
|
||||||
|
calledArgs.push(args);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
calledArgs,
|
||||||
|
callback,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCurrentNodeCalls(context: {
|
||||||
|
readonly actualArgs: readonly NodeStateChangeEventArgs[];
|
||||||
|
readonly expectedNodes: readonly TreeNode[];
|
||||||
|
readonly expectedOldStates: readonly TreeNodeStateDescriptor[];
|
||||||
|
readonly expectedNewStates: readonly TreeNodeStateDescriptor[];
|
||||||
|
}) {
|
||||||
|
const assertionMessage = buildAssertionMessage(
|
||||||
|
context.actualArgs,
|
||||||
|
context.expectedNodes,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(context.actualArgs).to.have.lengthOf(context.expectedNodes.length, assertionMessage);
|
||||||
|
|
||||||
|
const actualNodeIds = context.actualArgs.map((c) => c.node.id);
|
||||||
|
const expectedNodeIds = context.expectedNodes.map((node) => node.id);
|
||||||
|
expect(actualNodeIds).to.have.members(expectedNodeIds, assertionMessage);
|
||||||
|
|
||||||
|
const actualOldStates = context.actualArgs.map((c) => c.oldState);
|
||||||
|
expect(actualOldStates).to.have.deep.members(context.expectedOldStates, assertionMessage);
|
||||||
|
|
||||||
|
const actualNewStates = context.actualArgs.map((c) => c.newState);
|
||||||
|
expect(actualNewStates).to.have.deep.members(context.expectedNewStates, assertionMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAssertionMessage(
|
||||||
|
calledArgs: readonly NodeStateChangeEventArgs[],
|
||||||
|
nodes: readonly TreeNode[],
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
'\n',
|
||||||
|
`Expected nodes (${nodes.length}):`,
|
||||||
|
nodes.map((node) => `\tid: ${node.id}\n\tstate: ${JSON.stringify(node.state.current)}`).join('\n-\n'),
|
||||||
|
'\n',
|
||||||
|
`Actual called args (${calledArgs.length}):`,
|
||||||
|
calledArgs.map((args) => `\tid: ${args.node.id}\n\tnewState: ${JSON.stringify(args.newState)}`).join('\n-\n'),
|
||||||
|
'\n',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFlatCollection(nodes: readonly TreeNode[]): QueryableNodesStub {
|
||||||
|
return new QueryableNodesStub().withFlattenedNodes(nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
class UseNodeStateChangeAggregatorBuilder {
|
||||||
|
private treeWatcher: WatchSource<TreeRoot | undefined> = () => new TreeRootStub();
|
||||||
|
|
||||||
|
private currentTreeNodes: typeof useCurrentTreeNodes = new UseCurrentTreeNodesStub().get();
|
||||||
|
|
||||||
|
private events: UseAutoUnsubscribedEventsStub = new UseAutoUnsubscribedEventsStub();
|
||||||
|
|
||||||
|
public withTreeWatcher(treeWatcher: WatchSource<TreeRoot | undefined>): this {
|
||||||
|
this.treeWatcher = treeWatcher;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withCurrentTreeNodes(treeNodes: typeof useCurrentTreeNodes): this {
|
||||||
|
this.currentTreeNodes = treeNodes;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withEventsStub(events: UseAutoUnsubscribedEventsStub): this {
|
||||||
|
this.events = events;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public mountWrapperComponent() {
|
||||||
|
let returnObject: ReturnType<typeof useNodeStateChangeAggregator>;
|
||||||
|
const { treeWatcher, currentTreeNodes } = this;
|
||||||
|
const wrapper = shallowMount(
|
||||||
|
defineComponent({
|
||||||
|
setup() {
|
||||||
|
returnObject = useNodeStateChangeAggregator(treeWatcher, currentTreeNodes);
|
||||||
|
},
|
||||||
|
template: '<div></div>',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
provide: {
|
||||||
|
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||||
|
() => this.events.get(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
wrapper,
|
||||||
|
returnObject,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
import { shallowMount } from '@vue/test-utils';
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { TreeNodeStateChangedEmittedEvent } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeNodeStateChangedEmittedEvent';
|
import { shallowMount } from '@vue/test-utils';
|
||||||
import { useCollectionSelectionStateUpdater } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater';
|
import { useCollectionSelectionStateUpdater } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater';
|
||||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import { HierarchyAccessStub } from '@tests/unit/shared/Stubs/HierarchyAccessStub';
|
import { HierarchyAccessStub } from '@tests/unit/shared/Stubs/HierarchyAccessStub';
|
||||||
import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
|
import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
|
||||||
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||||
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
|
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 { 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 { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
|
||||||
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
|
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
|
||||||
|
import { TreeNodeStateChangedEmittedEventStub } from '@tests/unit/shared/Stubs/TreeNodeStateChangedEmittedEventStub';
|
||||||
|
|
||||||
describe('useCollectionSelectionStateUpdater', () => {
|
describe('useCollectionSelectionStateUpdater', () => {
|
||||||
describe('updateNodeSelection', () => {
|
describe('updateNodeSelection', () => {
|
||||||
@@ -19,16 +17,17 @@ describe('useCollectionSelectionStateUpdater', () => {
|
|||||||
it('does nothing', () => {
|
it('does nothing', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const { returnObject, useStateStub } = mountWrapperComponent();
|
const { returnObject, useStateStub } = mountWrapperComponent();
|
||||||
const mockEvent: TreeNodeStateChangedEmittedEvent = {
|
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||||
node: createTreeNodeStub({
|
.withNode(
|
||||||
isBranch: true,
|
createTreeNodeStub({
|
||||||
currentState: TreeNodeCheckState.Checked,
|
isBranch: true,
|
||||||
}),
|
currentState: TreeNodeCheckState.Checked,
|
||||||
change: new NodeStateChangedEventStub().withCheckStateChange({
|
}),
|
||||||
|
)
|
||||||
|
.withCheckStateChange({
|
||||||
oldState: TreeNodeCheckState.Checked,
|
oldState: TreeNodeCheckState.Checked,
|
||||||
newState: TreeNodeCheckState.Unchecked,
|
newState: TreeNodeCheckState.Unchecked,
|
||||||
}),
|
});
|
||||||
};
|
|
||||||
// act
|
// act
|
||||||
returnObject.updateNodeSelection(mockEvent);
|
returnObject.updateNodeSelection(mockEvent);
|
||||||
// assert
|
// assert
|
||||||
@@ -40,16 +39,17 @@ describe('useCollectionSelectionStateUpdater', () => {
|
|||||||
it('does nothing', () => {
|
it('does nothing', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const { returnObject, useStateStub } = mountWrapperComponent();
|
const { returnObject, useStateStub } = mountWrapperComponent();
|
||||||
const mockEvent: TreeNodeStateChangedEmittedEvent = {
|
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||||
node: createTreeNodeStub({
|
.withNode(
|
||||||
isBranch: false,
|
createTreeNodeStub({
|
||||||
currentState: TreeNodeCheckState.Checked,
|
isBranch: false,
|
||||||
}),
|
currentState: TreeNodeCheckState.Checked,
|
||||||
change: new NodeStateChangedEventStub().withCheckStateChange({
|
}),
|
||||||
|
)
|
||||||
|
.withCheckStateChange({
|
||||||
oldState: TreeNodeCheckState.Checked,
|
oldState: TreeNodeCheckState.Checked,
|
||||||
newState: TreeNodeCheckState.Checked,
|
newState: TreeNodeCheckState.Checked,
|
||||||
}),
|
});
|
||||||
};
|
|
||||||
// act
|
// act
|
||||||
returnObject.updateNodeSelection(mockEvent);
|
returnObject.updateNodeSelection(mockEvent);
|
||||||
// assert
|
// assert
|
||||||
@@ -64,19 +64,17 @@ describe('useCollectionSelectionStateUpdater', () => {
|
|||||||
const selectionStub = new UserSelectionStub([]);
|
const selectionStub = new UserSelectionStub([]);
|
||||||
selectionStub.isSelected = () => false;
|
selectionStub.isSelected = () => false;
|
||||||
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
|
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
|
||||||
const mockEvent: TreeNodeStateChangedEmittedEvent = {
|
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||||
node: new TreeNodeStub()
|
.withNode(
|
||||||
.withHierarchy(new HierarchyAccessStub().withIsBranchNode(false))
|
createTreeNodeStub({
|
||||||
.withState(new TreeNodeStateAccessStub().withCurrent(
|
isBranch: false,
|
||||||
new TreeNodeStateDescriptorStub().withCheckState(
|
currentState: TreeNodeCheckState.Checked,
|
||||||
TreeNodeCheckState.Checked,
|
}),
|
||||||
),
|
)
|
||||||
)),
|
.withCheckStateChange({
|
||||||
change: new NodeStateChangedEventStub().withCheckStateChange({
|
|
||||||
oldState: TreeNodeCheckState.Unchecked,
|
oldState: TreeNodeCheckState.Unchecked,
|
||||||
newState: TreeNodeCheckState.Checked,
|
newState: TreeNodeCheckState.Checked,
|
||||||
}),
|
});
|
||||||
};
|
|
||||||
// act
|
// act
|
||||||
returnObject.updateNodeSelection(mockEvent);
|
returnObject.updateNodeSelection(mockEvent);
|
||||||
// assert
|
// assert
|
||||||
@@ -91,16 +89,17 @@ describe('useCollectionSelectionStateUpdater', () => {
|
|||||||
const selectionStub = new UserSelectionStub([]);
|
const selectionStub = new UserSelectionStub([]);
|
||||||
selectionStub.isSelected = () => true;
|
selectionStub.isSelected = () => true;
|
||||||
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
|
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
|
||||||
const mockEvent: TreeNodeStateChangedEmittedEvent = {
|
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||||
node: createTreeNodeStub({
|
.withNode(
|
||||||
isBranch: false,
|
createTreeNodeStub({
|
||||||
currentState: TreeNodeCheckState.Checked,
|
isBranch: false,
|
||||||
}),
|
currentState: TreeNodeCheckState.Checked,
|
||||||
change: new NodeStateChangedEventStub().withCheckStateChange({
|
}),
|
||||||
|
)
|
||||||
|
.withCheckStateChange({
|
||||||
oldState: TreeNodeCheckState.Unchecked,
|
oldState: TreeNodeCheckState.Unchecked,
|
||||||
newState: TreeNodeCheckState.Checked,
|
newState: TreeNodeCheckState.Checked,
|
||||||
}),
|
});
|
||||||
};
|
|
||||||
// act
|
// act
|
||||||
returnObject.updateNodeSelection(mockEvent);
|
returnObject.updateNodeSelection(mockEvent);
|
||||||
// assert
|
// assert
|
||||||
@@ -115,16 +114,17 @@ describe('useCollectionSelectionStateUpdater', () => {
|
|||||||
const selectionStub = new UserSelectionStub([]);
|
const selectionStub = new UserSelectionStub([]);
|
||||||
selectionStub.isSelected = () => true;
|
selectionStub.isSelected = () => true;
|
||||||
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
|
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
|
||||||
const mockEvent: TreeNodeStateChangedEmittedEvent = {
|
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||||
node: createTreeNodeStub({
|
.withNode(
|
||||||
isBranch: false,
|
createTreeNodeStub({
|
||||||
currentState: TreeNodeCheckState.Unchecked,
|
isBranch: false,
|
||||||
}),
|
currentState: TreeNodeCheckState.Unchecked,
|
||||||
change: new NodeStateChangedEventStub().withCheckStateChange({
|
}),
|
||||||
|
)
|
||||||
|
.withCheckStateChange({
|
||||||
oldState: TreeNodeCheckState.Checked,
|
oldState: TreeNodeCheckState.Checked,
|
||||||
newState: TreeNodeCheckState.Unchecked,
|
newState: TreeNodeCheckState.Unchecked,
|
||||||
}),
|
});
|
||||||
};
|
|
||||||
// act
|
// act
|
||||||
returnObject.updateNodeSelection(mockEvent);
|
returnObject.updateNodeSelection(mockEvent);
|
||||||
// assert
|
// assert
|
||||||
@@ -139,16 +139,17 @@ describe('useCollectionSelectionStateUpdater', () => {
|
|||||||
const selectionStub = new UserSelectionStub([]);
|
const selectionStub = new UserSelectionStub([]);
|
||||||
selectionStub.isSelected = () => false;
|
selectionStub.isSelected = () => false;
|
||||||
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
|
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
|
||||||
const mockEvent: TreeNodeStateChangedEmittedEvent = {
|
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||||
node: createTreeNodeStub({
|
.withNode(
|
||||||
isBranch: false,
|
createTreeNodeStub({
|
||||||
currentState: TreeNodeCheckState.Unchecked,
|
isBranch: false,
|
||||||
}),
|
currentState: TreeNodeCheckState.Unchecked,
|
||||||
change: new NodeStateChangedEventStub().withCheckStateChange({
|
}),
|
||||||
|
)
|
||||||
|
.withCheckStateChange({
|
||||||
oldState: TreeNodeCheckState.Checked,
|
oldState: TreeNodeCheckState.Checked,
|
||||||
newState: TreeNodeCheckState.Unchecked,
|
newState: TreeNodeCheckState.Unchecked,
|
||||||
}),
|
});
|
||||||
};
|
|
||||||
// act
|
// act
|
||||||
returnObject.updateNodeSelection(mockEvent);
|
returnObject.updateNodeSelection(mockEvent);
|
||||||
// assert
|
// assert
|
||||||
@@ -186,9 +187,5 @@ function createTreeNodeStub(scenario: {
|
|||||||
}) {
|
}) {
|
||||||
return new TreeNodeStub()
|
return new TreeNodeStub()
|
||||||
.withHierarchy(new HierarchyAccessStub().withIsBranchNode(scenario.isBranch))
|
.withHierarchy(new HierarchyAccessStub().withIsBranchNode(scenario.isBranch))
|
||||||
.withState(new TreeNodeStateAccessStub().withCurrent(
|
.withState(new TreeNodeStateAccessStub().withCurrentCheckState(scenario.currentState));
|
||||||
new TreeNodeStateDescriptorStub().withCheckState(
|
|
||||||
scenario.currentState,
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|||||||
19
tests/unit/shared/Stubs/DelaySchedulerStub.ts
Normal file
19
tests/unit/shared/Stubs/DelaySchedulerStub.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { DelayScheduler } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/DelayScheduler';
|
||||||
|
|
||||||
|
export class DelaySchedulerStub implements DelayScheduler {
|
||||||
|
public nextCallback: () => void | undefined = undefined;
|
||||||
|
|
||||||
|
public scheduleNext(callback: () => void): void {
|
||||||
|
this.nextCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public runNextScheduled(): void {
|
||||||
|
if (!this.nextCallback) {
|
||||||
|
throw new Error('no callback is scheduled');
|
||||||
|
}
|
||||||
|
// Store the callback to prevent changes to this.nextCallback during execution
|
||||||
|
const callback = this.nextCallback;
|
||||||
|
this.nextCallback = undefined;
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ export class EventSubscriptionCollectionStub
|
|||||||
methodName: 'unsubscribeAllAndRegister',
|
methodName: 'unsubscribeAllAndRegister',
|
||||||
args: [subscriptions],
|
args: [subscriptions],
|
||||||
});
|
});
|
||||||
this.unsubscribeAll();
|
// Not calling other methods to avoid registering method calls.
|
||||||
this.register(subscriptions);
|
this.subscriptions.splice(0, this.subscriptions.length, ...subscriptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,9 @@ export class HierarchyAccessStub implements HierarchyAccess {
|
|||||||
this.isBranchNode = value;
|
this.isBranchNode = value;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public withIsLeafNode(value: boolean): this {
|
||||||
|
this.isLeafNode = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
tests/unit/shared/Stubs/NodeStateChangeEventArgsStub.ts
Normal file
48
tests/unit/shared/Stubs/NodeStateChangeEventArgsStub.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NodeStateChangeEventArgs } from '@/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator';
|
||||||
|
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
|
||||||
|
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
|
||||||
|
import { TreeNodeStub } from './TreeNodeStub';
|
||||||
|
import { TreeNodeStateDescriptorStub } from './TreeNodeStateDescriptorStub';
|
||||||
|
import { HierarchyAccessStub } from './HierarchyAccessStub';
|
||||||
|
|
||||||
|
export class NodeStateChangeEventArgsStub implements NodeStateChangeEventArgs {
|
||||||
|
public node: TreeNode = new TreeNodeStub()
|
||||||
|
.withId(`[${NodeStateChangeEventArgsStub.name}] node-stub`);
|
||||||
|
|
||||||
|
public newState: TreeNodeStateDescriptor = new TreeNodeStateDescriptorStub();
|
||||||
|
|
||||||
|
public oldState?: TreeNodeStateDescriptor = new TreeNodeStateDescriptorStub();
|
||||||
|
|
||||||
|
public withNode(node: TreeNode): this {
|
||||||
|
this.node = node;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withNewState(newState: TreeNodeStateDescriptor): this {
|
||||||
|
this.newState = newState;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withOldState(oldState: TreeNodeStateDescriptor): this {
|
||||||
|
this.oldState = oldState;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChangeEvent(scenario: {
|
||||||
|
readonly oldState?: TreeNodeStateDescriptor;
|
||||||
|
readonly newState: TreeNodeStateDescriptor;
|
||||||
|
readonly hierarchyBuilder?: (hierarchy: HierarchyAccessStub) => HierarchyAccessStub;
|
||||||
|
}) {
|
||||||
|
let nodeHierarchy = new HierarchyAccessStub();
|
||||||
|
if (scenario.hierarchyBuilder) {
|
||||||
|
nodeHierarchy = scenario.hierarchyBuilder(nodeHierarchy);
|
||||||
|
}
|
||||||
|
const changeEvent = new NodeStateChangeEventArgsStub()
|
||||||
|
.withOldState(scenario.oldState)
|
||||||
|
.withNewState(scenario.newState)
|
||||||
|
.withNode(new TreeNodeStub()
|
||||||
|
.withId(`[${createChangeEvent.name}] node-stub`)
|
||||||
|
.withHierarchy(nodeHierarchy));
|
||||||
|
return changeEvent;
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
|
|
||||||
import { NodeStateChangedEvent } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess';
|
import { NodeStateChangedEvent } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess';
|
||||||
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
|
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
|
||||||
import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub';
|
import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub';
|
||||||
@@ -17,17 +16,4 @@ export class NodeStateChangedEventStub implements NodeStateChangedEvent {
|
|||||||
this.oldState = oldState;
|
this.oldState = oldState;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public withCheckStateChange(change: {
|
|
||||||
readonly oldState: TreeNodeCheckState,
|
|
||||||
readonly newState: TreeNodeCheckState,
|
|
||||||
}) {
|
|
||||||
return this
|
|
||||||
.withOldState(
|
|
||||||
new TreeNodeStateDescriptorStub().withCheckState(change.oldState),
|
|
||||||
)
|
|
||||||
.withNewState(
|
|
||||||
new TreeNodeStateDescriptorStub().withCheckState(change.newState),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
|
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
|
||||||
import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes';
|
import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes';
|
||||||
|
import { TreeNodeStub } from './TreeNodeStub';
|
||||||
|
|
||||||
export class QueryableNodesStub implements QueryableNodes {
|
export class QueryableNodesStub implements QueryableNodes {
|
||||||
public rootNodes: readonly TreeNode[];
|
public rootNodes: readonly TreeNode[] = [
|
||||||
|
new TreeNodeStub().withId(`[${QueryableNodesStub.name}] root-node-stub`),
|
||||||
|
];
|
||||||
|
|
||||||
public flattenedNodes: readonly TreeNode[];
|
public flattenedNodes: readonly TreeNode[] = [
|
||||||
|
new TreeNodeStub().withId(`[${QueryableNodesStub.name}] flattened-node-stub-1`),
|
||||||
|
new TreeNodeStub().withId(`[${QueryableNodesStub.name}] flattened-node-stub-2`),
|
||||||
|
];
|
||||||
|
|
||||||
public getNodeById(): TreeNode {
|
public getNodeById(): TreeNode {
|
||||||
throw new Error('Method not implemented.');
|
throw new Error('Method not implemented.');
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { SingleNodeFocusManager } from '@/presentation/components/Scripts/View/T
|
|||||||
import { TreeNodeStub } from './TreeNodeStub';
|
import { TreeNodeStub } from './TreeNodeStub';
|
||||||
|
|
||||||
export class SingleNodeFocusManagerStub implements SingleNodeFocusManager {
|
export class SingleNodeFocusManagerStub implements SingleNodeFocusManager {
|
||||||
public currentSingleFocusedNode: TreeNode = new TreeNodeStub();
|
public currentSingleFocusedNode: TreeNode = new TreeNodeStub()
|
||||||
|
.withId(`[${SingleNodeFocusManagerStub.name}] focused-node-stub`);
|
||||||
|
|
||||||
setSingleFocus(): void { /* NOOP */ }
|
setSingleFocus(): void { /* NOOP */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes';
|
import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes';
|
||||||
import { TreeNodeCollection } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeCollection';
|
import { TreeNodeCollection } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeCollection';
|
||||||
import { EventSourceStub } from './EventSourceStub';
|
import { EventSourceStub } from './EventSourceStub';
|
||||||
|
import { QueryableNodesStub } from './QueryableNodesStub';
|
||||||
|
|
||||||
export class TreeNodeCollectionStub implements TreeNodeCollection {
|
export class TreeNodeCollectionStub implements TreeNodeCollection {
|
||||||
public nodes: QueryableNodes;
|
public nodes: QueryableNodes = new QueryableNodesStub();
|
||||||
|
|
||||||
public nodesUpdated = new EventSourceStub<QueryableNodes>();
|
public nodesUpdated = new EventSourceStub<QueryableNodes>();
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export function createTreeNodeParserStub() {
|
|||||||
if (result !== undefined) {
|
if (result !== undefined) {
|
||||||
return result.result;
|
return result.result;
|
||||||
}
|
}
|
||||||
return data.map(() => new TreeNodeStub());
|
return data.map(() => new TreeNodeStub()
|
||||||
|
.withId(`[${createTreeNodeParserStub.name}] parsed-node-stub`));
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
registerScenario,
|
registerScenario,
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { NodeStateChangedEvent, TreeNodeStateAccess, TreeNodeStateTransaction } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess';
|
import { NodeStateChangedEvent, TreeNodeStateAccess, TreeNodeStateTransaction } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess';
|
||||||
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
|
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
|
||||||
|
import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
|
||||||
import { TreeNodeStateDescriptorStub } from './TreeNodeStateDescriptorStub';
|
import { TreeNodeStateDescriptorStub } from './TreeNodeStateDescriptorStub';
|
||||||
import { EventSourceStub } from './EventSourceStub';
|
import { EventSourceStub } from './EventSourceStub';
|
||||||
import { TreeNodeStateTransactionStub } from './TreeNodeStateTransactionStub';
|
import { TreeNodeStateTransactionStub } from './TreeNodeStateTransactionStub';
|
||||||
|
|
||||||
export class TreeNodeStateAccessStub implements TreeNodeStateAccess {
|
export class TreeNodeStateAccessStub implements TreeNodeStateAccess {
|
||||||
|
public isStateModificationRequested = false;
|
||||||
|
|
||||||
public current: TreeNodeStateDescriptor = new TreeNodeStateDescriptorStub();
|
public current: TreeNodeStateDescriptor = new TreeNodeStateDescriptorStub();
|
||||||
|
|
||||||
public changed: EventSourceStub<NodeStateChangedEvent> = new EventSourceStub();
|
public changed: EventSourceStub<NodeStateChangedEvent> = new EventSourceStub();
|
||||||
@@ -32,6 +35,7 @@ export class TreeNodeStateAccessStub implements TreeNodeStateAccess {
|
|||||||
oldState,
|
oldState,
|
||||||
newState,
|
newState,
|
||||||
});
|
});
|
||||||
|
this.isStateModificationRequested = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public triggerStateChangedEvent(event: NodeStateChangedEvent) {
|
public triggerStateChangedEvent(event: NodeStateChangedEvent) {
|
||||||
@@ -42,4 +46,26 @@ export class TreeNodeStateAccessStub implements TreeNodeStateAccess {
|
|||||||
this.current = state;
|
this.current = state;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public withCurrentCheckState(checkState: TreeNodeCheckState): this {
|
||||||
|
return this.withCurrent(
|
||||||
|
new TreeNodeStateDescriptorStub()
|
||||||
|
.withCheckState(checkState),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public withCurrentVisibility(isVisible: boolean): this {
|
||||||
|
return this.withCurrent(
|
||||||
|
new TreeNodeStateDescriptorStub()
|
||||||
|
.withVisibility(isVisible),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAccessStubsFromCheckStates(
|
||||||
|
states: readonly TreeNodeCheckState[],
|
||||||
|
): TreeNodeStateAccessStub[] {
|
||||||
|
return states.map(
|
||||||
|
(checkState) => new TreeNodeStateAccessStub().withCurrentCheckState(checkState),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { TreeNodeStateChangedEmittedEvent } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeNodeStateChangedEmittedEvent';
|
||||||
|
import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
|
||||||
|
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
|
||||||
|
import { ReadOnlyTreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
|
||||||
|
import { TreeNodeStateDescriptorStub } from './TreeNodeStateDescriptorStub';
|
||||||
|
|
||||||
|
export class TreeNodeStateChangedEmittedEventStub implements TreeNodeStateChangedEmittedEvent {
|
||||||
|
public node: ReadOnlyTreeNode;
|
||||||
|
|
||||||
|
public oldState?: TreeNodeStateDescriptor;
|
||||||
|
|
||||||
|
public newState: TreeNodeStateDescriptor;
|
||||||
|
|
||||||
|
public withNode(node: ReadOnlyTreeNode): this {
|
||||||
|
this.node = node;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withNewState(newState: TreeNodeStateDescriptor): this {
|
||||||
|
this.newState = newState;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withOldState(oldState: TreeNodeStateDescriptor): this {
|
||||||
|
this.oldState = oldState;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withCheckStateChange(change: {
|
||||||
|
readonly oldState: TreeNodeCheckState,
|
||||||
|
readonly newState: TreeNodeCheckState,
|
||||||
|
}) {
|
||||||
|
return this
|
||||||
|
.withOldState(
|
||||||
|
new TreeNodeStateDescriptorStub().withCheckState(change.oldState),
|
||||||
|
)
|
||||||
|
.withNewState(
|
||||||
|
new TreeNodeStateDescriptorStub().withCheckState(change.newState),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export class TreeNodeStateDescriptorStub implements TreeNodeStateDescriptor {
|
|||||||
|
|
||||||
public isFocused = false;
|
public isFocused = false;
|
||||||
|
|
||||||
public withFocusState(isFocused: boolean): this {
|
public withFocus(isFocused: boolean): this {
|
||||||
this.isFocused = isFocused;
|
this.isFocused = isFocused;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -21,4 +21,9 @@ export class TreeNodeStateDescriptorStub implements TreeNodeStateDescriptor {
|
|||||||
this.checkState = checkState;
|
this.checkState = checkState;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public withVisibility(isVisible: boolean): this {
|
||||||
|
this.isVisible = isVisible;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,24 @@ import { TreeNodeStateAccess } from '@/presentation/components/Scripts/View/Tree
|
|||||||
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
|
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
|
||||||
import { NodeMetadataStub } from './NodeMetadataStub';
|
import { NodeMetadataStub } from './NodeMetadataStub';
|
||||||
import { HierarchyAccessStub } from './HierarchyAccessStub';
|
import { HierarchyAccessStub } from './HierarchyAccessStub';
|
||||||
|
import { TreeNodeStateAccessStub } from './TreeNodeStateAccessStub';
|
||||||
|
|
||||||
export class TreeNodeStub implements TreeNode {
|
export class TreeNodeStub implements TreeNode {
|
||||||
public state: TreeNodeStateAccess;
|
public static fromStates(
|
||||||
|
states: readonly TreeNodeStateAccess[],
|
||||||
|
): TreeNodeStub[] {
|
||||||
|
return states.map(
|
||||||
|
(state) => new TreeNodeStub()
|
||||||
|
.withId(`[${TreeNodeStub.fromStates.name}] node-stub`)
|
||||||
|
.withState(state),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public state: TreeNodeStateAccess = new TreeNodeStateAccessStub();
|
||||||
|
|
||||||
public hierarchy: HierarchyAccess = new HierarchyAccessStub();
|
public hierarchy: HierarchyAccess = new HierarchyAccessStub();
|
||||||
|
|
||||||
public id: string;
|
public id = 'tree-node-stub-id';
|
||||||
|
|
||||||
public metadata?: object = new NodeMetadataStub();
|
public metadata?: object = new NodeMetadataStub();
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hook
|
|||||||
import { EventSubscriptionCollectionStub } from './EventSubscriptionCollectionStub';
|
import { EventSubscriptionCollectionStub } from './EventSubscriptionCollectionStub';
|
||||||
|
|
||||||
export class UseAutoUnsubscribedEventsStub {
|
export class UseAutoUnsubscribedEventsStub {
|
||||||
|
public readonly events = new EventSubscriptionCollectionStub();
|
||||||
|
|
||||||
public get(): ReturnType<typeof useAutoUnsubscribedEvents> {
|
public get(): ReturnType<typeof useAutoUnsubscribedEvents> {
|
||||||
return {
|
return {
|
||||||
events: new EventSubscriptionCollectionStub(),
|
events: this.events,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
tests/unit/shared/Stubs/UseCurrentTreeNodesStub.ts
Normal file
32
tests/unit/shared/Stubs/UseCurrentTreeNodesStub.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
WatchSource, readonly, shallowRef, triggerRef,
|
||||||
|
} from 'vue';
|
||||||
|
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
|
||||||
|
import { useCurrentTreeNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes';
|
||||||
|
import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes';
|
||||||
|
import { QueryableNodesStub } from './QueryableNodesStub';
|
||||||
|
|
||||||
|
export class UseCurrentTreeNodesStub {
|
||||||
|
public treeWatcher: WatchSource<TreeRoot> | undefined;
|
||||||
|
|
||||||
|
private nodes = shallowRef<QueryableNodes>(new QueryableNodesStub());
|
||||||
|
|
||||||
|
public withQueryableNodes(nodes: QueryableNodes): this {
|
||||||
|
this.nodes.value = nodes;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public triggerNewNodes(nodes: QueryableNodes) {
|
||||||
|
this.nodes.value = nodes;
|
||||||
|
triggerRef(this.nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(): typeof useCurrentTreeNodes {
|
||||||
|
return (treeWatcher: WatchSource<TreeRoot>) => {
|
||||||
|
this.treeWatcher = treeWatcher;
|
||||||
|
return {
|
||||||
|
nodes: readonly(this.nodes),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
33
tests/unit/shared/Stubs/UseNodeStateChangeAggregatorStub.ts
Normal file
33
tests/unit/shared/Stubs/UseNodeStateChangeAggregatorStub.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { WatchSource } from 'vue';
|
||||||
|
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
|
||||||
|
import {
|
||||||
|
NodeStateChangeEventArgs,
|
||||||
|
NodeStateChangeEventCallback,
|
||||||
|
useNodeStateChangeAggregator,
|
||||||
|
} from '@/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator';
|
||||||
|
|
||||||
|
export class UseNodeStateChangeAggregatorStub {
|
||||||
|
public callback: NodeStateChangeEventCallback | undefined;
|
||||||
|
|
||||||
|
public treeWatcher: WatchSource<TreeRoot> | undefined;
|
||||||
|
|
||||||
|
public onNodeStateChange(callback: NodeStateChangeEventCallback) {
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public notifyChange(change: NodeStateChangeEventArgs) {
|
||||||
|
if (!this.callback) {
|
||||||
|
throw new Error('callback is not set');
|
||||||
|
}
|
||||||
|
this.callback(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(): typeof useNodeStateChangeAggregator {
|
||||||
|
return (treeWatcher: WatchSource<TreeRoot>) => {
|
||||||
|
this.treeWatcher = treeWatcher;
|
||||||
|
return {
|
||||||
|
onNodeStateChange: this.onNodeStateChange.bind(this),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user