diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeNodeStateChangedEmittedEvent.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeNodeStateChangedEmittedEvent.ts index 270f8b38..4df2b00c 100644 --- a/src/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeNodeStateChangedEmittedEvent.ts +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeNodeStateChangedEmittedEvent.ts @@ -1,7 +1,8 @@ -import { NodeStateChangedEvent } from '../Node/State/StateAccess'; +import { TreeNodeStateDescriptor } from '../Node/State/StateDescriptor'; import { ReadOnlyTreeNode } from '../Node/TreeNode'; export interface TreeNodeStateChangedEmittedEvent { - readonly change: NodeStateChangedEvent; readonly node: ReadOnlyTreeNode; + readonly oldState?: TreeNodeStateDescriptor; + readonly newState: TreeNodeStateDescriptor; } diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/DelayScheduler.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/DelayScheduler.ts new file mode 100644 index 00000000..4aac73b7 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/DelayScheduler.ts @@ -0,0 +1,3 @@ +export interface DelayScheduler { + scheduleNext(callback: () => void, delayInMs: number): void; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/TimeoutDelayScheduler.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/TimeoutDelayScheduler.ts new file mode 100644 index 00000000..55ce7015 --- /dev/null +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/TimeoutDelayScheduler.ts @@ -0,0 +1,28 @@ +import { DelayScheduler } from './DelayScheduler'; + +export class TimeoutDelayScheduler implements DelayScheduler { + private timeoutId: ReturnType | 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): void; + setTimeout(callback: () => void, delayInMs: number): ReturnType; +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering.ts b/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering.ts index bd03a36a..15915d4f 100644 --- a/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering.ts +++ b/src/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering.ts @@ -6,39 +6,37 @@ import { useNodeStateChangeAggregator } from '../UseNodeStateChangeAggregator'; import { TreeRoot } from '../TreeRoot/TreeRoot'; import { useCurrentTreeNodes } from '../UseCurrentTreeNodes'; 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. */ export function useGradualNodeRendering( treeWatcher: WatchSource, + useChangeAggregator = useNodeStateChangeAggregator, + useTreeNodes = useCurrentTreeNodes, + scheduler: DelayScheduler = new TimeoutDelayScheduler(), + initialBatchSize = 30, + subsequentBatchSize = 5, ): NodeRenderingStrategy { const nodesToRender = new Set(); const nodesBeingRendered = shallowRef(new Set()); - let isFirstRender = true; let isRenderingInProgress = false; const renderingDelayInMs = 50; - const initialBatchSize = 30; - const subsequentBatchSize = 5; - const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher); - const { nodes } = useCurrentTreeNodes(treeWatcher); + const { onNodeStateChange } = useChangeAggregator(treeWatcher); + const { nodes } = useTreeNodes(treeWatcher); const orderedNodes = computed(() => nodes.value.flattenedNodes); - watch(() => orderedNodes.value, (newNodes) => { - newNodes.forEach((node) => updateNodeRenderQueue(node)); - }, { immediate: true }); - - function updateNodeRenderQueue(node: ReadOnlyTreeNode) { - if (node.state.current.isVisible + function updateNodeRenderQueue(node: ReadOnlyTreeNode, isVisible: boolean) { + if (isVisible && !nodesToRender.has(node) && !nodesBeingRendered.value.has(node)) { nodesToRender.add(node); - if (!isRenderingInProgress) { - scheduleRendering(); - } - } else if (!node.state.current.isVisible) { + beginRendering(); + } else if (!isVisible) { if (nodesToRender.has(node)) { nodesToRender.delete(node); } @@ -49,47 +47,58 @@ export function useGradualNodeRendering( } } - onNodeStateChange((node, change) => { - if (change.newState.isVisible === change.oldState.isVisible) { + watch(() => orderedNodes.value, (newNodes) => { + nodesToRender.clear(); + nodesBeingRendered.value.clear(); + if (!newNodes?.length) { + triggerRef(nodesBeingRendered); 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 scheduleRendering() { - if (isFirstRender) { - renderNodeBatch(); - isFirstRender = false; - } else { - const delayScheduler = new DelayScheduler(renderingDelayInMs); - delayScheduler.schedule(renderNodeBatch); + function beginRendering() { + if (isRenderingInProgress) { + return; } + renderNextBatch(initialBatchSize); } - function renderNodeBatch() { + function renderNextBatch(batchSize: number) { if (nodesToRender.size === 0) { isRenderingInProgress = false; return; } isRenderingInProgress = true; - const batchSize = isFirstRender ? initialBatchSize : subsequentBatchSize; const sortedNodes = Array.from(nodesToRender).sort( (a, b) => orderedNodes.value.indexOf(a) - orderedNodes.value.indexOf(b), ); const currentBatch = sortedNodes.slice(0, batchSize); + if (currentBatch.length === 0) { + return; + } currentBatch.forEach((node) => { nodesToRender.delete(node); nodesBeingRendered.value.add(node); }); triggerRef(nodesBeingRendered); - if (nodesToRender.size > 0) { - scheduleRendering(); - } + scheduler.scheduleNext( + () => renderNextBatch(subsequentBatchSize), + renderingDelayInMs, + ); } - function shouldNodeBeRendered(node: ReadOnlyTreeNode) { + function shouldNodeBeRendered(node: ReadOnlyTreeNode): boolean { return nodesBeingRendered.value.has(node); } @@ -97,21 +106,3 @@ export function useGradualNodeRendering( shouldRender: shouldNodeBeRendered, }; } - -class DelayScheduler { - private timeoutId: ReturnType = 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; - } - } -} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/TreeView.vue b/src/presentation/components/Scripts/View/Tree/TreeView/TreeView.vue index 28bb8101..de817bc1 100644 --- a/src/presentation/components/Scripts/View/Tree/TreeView/TreeView.vue +++ b/src/presentation/components/Scripts/View/Tree/TreeView/TreeView.vue @@ -68,8 +68,13 @@ export default defineComponent({ const nodeRenderingScheduler = useGradualNodeRendering(() => 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(() => { diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateChildrenCheckState.ts b/src/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateChildrenCheckState.ts index 66d62777..2b1d985c 100644 --- a/src/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateChildrenCheckState.ts +++ b/src/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateChildrenCheckState.ts @@ -6,14 +6,15 @@ import { TreeNodeCheckState } from './Node/State/CheckState'; export function useAutoUpdateChildrenCheckState( treeWatcher: WatchSource, + useChangeAggregator = useNodeStateChangeAggregator, ) { - const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher); + const { onNodeStateChange } = useChangeAggregator(treeWatcher); - onNodeStateChange((node, change) => { - if (change.newState.checkState === change.oldState.checkState) { + onNodeStateChange((change) => { + if (change.newState.checkState === change.oldState?.checkState) { return; } - updateChildrenCheckedState(node.hierarchy, change.newState.checkState); + updateChildrenCheckedState(change.node.hierarchy, change.newState.checkState); }); } diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateParentCheckState.ts b/src/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateParentCheckState.ts index 13be2fbf..2be153b4 100644 --- a/src/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateParentCheckState.ts +++ b/src/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateParentCheckState.ts @@ -7,14 +7,15 @@ import { ReadOnlyTreeNode } from './Node/TreeNode'; export function useAutoUpdateParentCheckState( treeWatcher: WatchSource, + useChangeAggregator = useNodeStateChangeAggregator, ) { - const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher); + const { onNodeStateChange } = useChangeAggregator(treeWatcher); - onNodeStateChange((node, change) => { - if (change.newState.checkState === change.oldState.checkState) { + onNodeStateChange((change) => { + if (change.newState.checkState === change.oldState?.checkState) { return; } - updateNodeParentCheckedState(node.hierarchy); + updateNodeParentCheckedState(change.node.hierarchy); }); } diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes.ts b/src/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes.ts index d08aee11..ba422f3d 100644 --- a/src/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes.ts +++ b/src/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes.ts @@ -8,7 +8,7 @@ import { QueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes'; export function useCurrentTreeNodes(treeWatcher: WatchSource) { const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)(); - const tree = ref(); + const tree = ref(); const nodes = ref(); watch(treeWatcher, (newTree) => { diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.ts b/src/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.ts index 70c9a5bc..bfb573b4 100644 --- a/src/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.ts +++ b/src/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.ts @@ -1,35 +1,83 @@ -import { WatchSource, inject, watch } from 'vue'; +import { + WatchSource, inject, watch, ref, +} from 'vue'; import { InjectionKeys } from '@/presentation/injectionSymbols'; +import { IEventSubscription } from '@/infrastructure/Events/IEventSource'; import { TreeRoot } from './TreeRoot/TreeRoot'; import { TreeNode } from './Node/TreeNode'; import { useCurrentTreeNodes } from './UseCurrentTreeNodes'; -import { NodeStateChangedEvent } from './Node/State/StateAccess'; +import { TreeNodeStateDescriptor } from './Node/State/StateDescriptor'; -type NodeStateChangeEventCallback = ( - node: TreeNode, - stateChange: NodeStateChangedEvent, -) => void; +export type NodeStateChangeEventCallback = (args: NodeStateChangeEventArgs) => void; -export function useNodeStateChangeAggregator(treeWatcher: WatchSource) { - const { nodes } = useCurrentTreeNodes(treeWatcher); +export function useNodeStateChangeAggregator( + treeWatcher: WatchSource, + useTreeNodes = useCurrentTreeNodes, +) { + const { nodes } = useTreeNodes(treeWatcher); const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)(); - const onNodeChangeCallbacks = new Array(); + const onNodeChangeCallback = ref(); - watch(() => nodes.value, (newNodes) => { - events.unsubscribeAll(); - newNodes.flattenedNodes.forEach((node) => { - events.register([ - node.state.changed.on((stateChange) => { - onNodeChangeCallbacks.forEach((callback) => callback(node, stateChange)); - }), - ]); - }); + watch([ + () => nodes.value, + () => onNodeChangeCallback.value, + ], ([newNodes, callback]) => { + if (!callback) { // might not be registered yet + 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 { - onNodeStateChange: ( - callback: NodeStateChangeEventCallback, - ) => onNodeChangeCallbacks.push(callback), + onNodeStateChange, }; } + +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, + }); + })); +} diff --git a/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater.ts b/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater.ts index 4a06901b..981d1b2b 100644 --- a/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater.ts +++ b/src/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater.ts @@ -6,12 +6,12 @@ import { TreeNodeStateChangedEmittedEvent } from '../TreeView/Bindings/TreeNodeS export function useCollectionSelectionStateUpdater() { const { modifyCurrentState, currentState } = inject(InjectionKeys.useCollectionState)(); - const updateNodeSelection = (event: TreeNodeStateChangedEmittedEvent) => { - const { node } = event; + function updateNodeSelection(change: TreeNodeStateChangedEmittedEvent) { + const { node } = change; if (node.hierarchy.isBranchNode) { return; // A category, let TreeView handle this } - if (event.change.oldState.checkState === event.change.newState.checkState) { + if (change.oldState?.checkState === change.newState.checkState) { return; } if (node.state.current.checkState === TreeNodeCheckState.Checked) { @@ -30,7 +30,7 @@ export function useCollectionSelectionStateUpdater() { state.selection.removeSelectedScript(node.id); }); } - }; + } return { updateNodeSelection, diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Rendering/TimeoutDelayScheduler.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Rendering/TimeoutDelayScheduler.spec.ts new file mode 100644 index 00000000..5359c6a2 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Rendering/TimeoutDelayScheduler.spec.ts @@ -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 + implements TimeFunctions { + public clearTimeout(id: ReturnType): void { + this.registerMethodCall({ + methodName: 'clearTimeout', + args: [id], + }); + } + + public setTimeout(callback: () => void, delayInMs: number): ReturnType { + this.registerMethodCall({ + methodName: 'setTimeout', + args: [callback, delayInMs], + }); + return this.callHistory.filter((c) => c.methodName === 'setTimeout').length as unknown as ReturnType; + } +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering.spec.ts new file mode 100644 index 00000000..5c6be644 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering.spec.ts @@ -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 = () => 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): 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 { + return useGradualNodeRendering( + this.treeWatcher, + this.changeAggregator.get(), + this.currentTreeNodes.get(), + this.delayScheduler, + this.initialBatchSize, + this.subsequentBatchSize, + ); + } +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager.spec.ts index 61bf62fa..b516429b 100644 --- a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager.spec.ts @@ -87,6 +87,6 @@ describe('SingleNodeCollectionFocusManager', () => { function getNodeWithFocusState(isFocused: boolean): TreeNodeStub { return new TreeNodeStub() .withState(new TreeNodeStateAccessStub().withCurrent( - new TreeNodeStateDescriptorStub().withFocusState(isFocused), + new TreeNodeStateDescriptorStub().withFocus(isFocused), )); } diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateChildrenCheckState.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateChildrenCheckState.spec.ts new file mode 100644 index 00000000..53c333ee --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateChildrenCheckState.spec.ts @@ -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 = () => new TreeRootStub(); + + public withChangeAggregator(changeAggregator: UseNodeStateChangeAggregatorStub): this { + this.changeAggregator = changeAggregator; + return this; + } + + public withTreeWatcher(treeWatcher: WatchSource): this { + this.treeWatcher = treeWatcher; + return this; + } + + public call(): ReturnType { + return useAutoUpdateChildrenCheckState( + this.treeWatcher, + this.changeAggregator.get(), + ); + } +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateParentCheckState.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateParentCheckState.spec.ts new file mode 100644 index 00000000..e9c3af79 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseAutoUpdateParentCheckState.spec.ts @@ -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 = () => new TreeRootStub(); + + public withChangeAggregator(changeAggregator: UseNodeStateChangeAggregatorStub): this { + this.changeAggregator = changeAggregator; + return this; + } + + public withTreeWatcher(treeWatcher: WatchSource): this { + this.treeWatcher = treeWatcher; + return this; + } + + public call(): ReturnType { + return useAutoUpdateParentCheckState( + this.treeWatcher, + this.changeAggregator.get(), + ); + } +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.spec.ts new file mode 100644 index 00000000..6d435ef8 --- /dev/null +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeView/UseNodeStateChangeAggregator.spec.ts @@ -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; + }> = [ + { + 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(); + 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 = () => new TreeRootStub(); + + private currentTreeNodes: typeof useCurrentTreeNodes = new UseCurrentTreeNodesStub().get(); + + private events: UseAutoUnsubscribedEventsStub = new UseAutoUnsubscribedEventsStub(); + + public withTreeWatcher(treeWatcher: WatchSource): 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; + const { treeWatcher, currentTreeNodes } = this; + const wrapper = shallowMount( + defineComponent({ + setup() { + returnObject = useNodeStateChangeAggregator(treeWatcher, currentTreeNodes); + }, + template: '
', + }), + { + provide: { + [InjectionKeys.useAutoUnsubscribedEvents as symbol]: + () => this.events.get(), + }, + }, + ); + return { + wrapper, + returnObject, + }; + } +} diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater.spec.ts index 81585787..4dc32b34 100644 --- a/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater.spec.ts @@ -1,17 +1,15 @@ -import { shallowMount } from '@vue/test-utils'; 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 { InjectionKeys } from '@/presentation/injectionSymbols'; import { HierarchyAccessStub } from '@tests/unit/shared/Stubs/HierarchyAccessStub'; import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub'; import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub'; import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub'; -import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub'; import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState'; -import { NodeStateChangedEventStub } from '@tests/unit/shared/Stubs/NodeStateChangedEventStub'; import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub'; import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub'; +import { TreeNodeStateChangedEmittedEventStub } from '@tests/unit/shared/Stubs/TreeNodeStateChangedEmittedEventStub'; describe('useCollectionSelectionStateUpdater', () => { describe('updateNodeSelection', () => { @@ -19,16 +17,17 @@ describe('useCollectionSelectionStateUpdater', () => { it('does nothing', () => { // arrange const { returnObject, useStateStub } = mountWrapperComponent(); - const mockEvent: TreeNodeStateChangedEmittedEvent = { - node: createTreeNodeStub({ - isBranch: true, - currentState: TreeNodeCheckState.Checked, - }), - change: new NodeStateChangedEventStub().withCheckStateChange({ + const mockEvent = new TreeNodeStateChangedEmittedEventStub() + .withNode( + createTreeNodeStub({ + isBranch: true, + currentState: TreeNodeCheckState.Checked, + }), + ) + .withCheckStateChange({ oldState: TreeNodeCheckState.Checked, newState: TreeNodeCheckState.Unchecked, - }), - }; + }); // act returnObject.updateNodeSelection(mockEvent); // assert @@ -40,16 +39,17 @@ describe('useCollectionSelectionStateUpdater', () => { it('does nothing', () => { // arrange const { returnObject, useStateStub } = mountWrapperComponent(); - const mockEvent: TreeNodeStateChangedEmittedEvent = { - node: createTreeNodeStub({ - isBranch: false, - currentState: TreeNodeCheckState.Checked, - }), - change: new NodeStateChangedEventStub().withCheckStateChange({ + const mockEvent = new TreeNodeStateChangedEmittedEventStub() + .withNode( + createTreeNodeStub({ + isBranch: false, + currentState: TreeNodeCheckState.Checked, + }), + ) + .withCheckStateChange({ oldState: TreeNodeCheckState.Checked, newState: TreeNodeCheckState.Checked, - }), - }; + }); // act returnObject.updateNodeSelection(mockEvent); // assert @@ -64,19 +64,17 @@ describe('useCollectionSelectionStateUpdater', () => { const selectionStub = new UserSelectionStub([]); selectionStub.isSelected = () => false; useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub)); - const mockEvent: TreeNodeStateChangedEmittedEvent = { - node: new TreeNodeStub() - .withHierarchy(new HierarchyAccessStub().withIsBranchNode(false)) - .withState(new TreeNodeStateAccessStub().withCurrent( - new TreeNodeStateDescriptorStub().withCheckState( - TreeNodeCheckState.Checked, - ), - )), - change: new NodeStateChangedEventStub().withCheckStateChange({ + const mockEvent = new TreeNodeStateChangedEmittedEventStub() + .withNode( + createTreeNodeStub({ + isBranch: false, + currentState: TreeNodeCheckState.Checked, + }), + ) + .withCheckStateChange({ oldState: TreeNodeCheckState.Unchecked, newState: TreeNodeCheckState.Checked, - }), - }; + }); // act returnObject.updateNodeSelection(mockEvent); // assert @@ -91,16 +89,17 @@ describe('useCollectionSelectionStateUpdater', () => { const selectionStub = new UserSelectionStub([]); selectionStub.isSelected = () => true; useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub)); - const mockEvent: TreeNodeStateChangedEmittedEvent = { - node: createTreeNodeStub({ - isBranch: false, - currentState: TreeNodeCheckState.Checked, - }), - change: new NodeStateChangedEventStub().withCheckStateChange({ + const mockEvent = new TreeNodeStateChangedEmittedEventStub() + .withNode( + createTreeNodeStub({ + isBranch: false, + currentState: TreeNodeCheckState.Checked, + }), + ) + .withCheckStateChange({ oldState: TreeNodeCheckState.Unchecked, newState: TreeNodeCheckState.Checked, - }), - }; + }); // act returnObject.updateNodeSelection(mockEvent); // assert @@ -115,16 +114,17 @@ describe('useCollectionSelectionStateUpdater', () => { const selectionStub = new UserSelectionStub([]); selectionStub.isSelected = () => true; useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub)); - const mockEvent: TreeNodeStateChangedEmittedEvent = { - node: createTreeNodeStub({ - isBranch: false, - currentState: TreeNodeCheckState.Unchecked, - }), - change: new NodeStateChangedEventStub().withCheckStateChange({ + const mockEvent = new TreeNodeStateChangedEmittedEventStub() + .withNode( + createTreeNodeStub({ + isBranch: false, + currentState: TreeNodeCheckState.Unchecked, + }), + ) + .withCheckStateChange({ oldState: TreeNodeCheckState.Checked, newState: TreeNodeCheckState.Unchecked, - }), - }; + }); // act returnObject.updateNodeSelection(mockEvent); // assert @@ -139,16 +139,17 @@ describe('useCollectionSelectionStateUpdater', () => { const selectionStub = new UserSelectionStub([]); selectionStub.isSelected = () => false; useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub)); - const mockEvent: TreeNodeStateChangedEmittedEvent = { - node: createTreeNodeStub({ - isBranch: false, - currentState: TreeNodeCheckState.Unchecked, - }), - change: new NodeStateChangedEventStub().withCheckStateChange({ + const mockEvent = new TreeNodeStateChangedEmittedEventStub() + .withNode( + createTreeNodeStub({ + isBranch: false, + currentState: TreeNodeCheckState.Unchecked, + }), + ) + .withCheckStateChange({ oldState: TreeNodeCheckState.Checked, newState: TreeNodeCheckState.Unchecked, - }), - }; + }); // act returnObject.updateNodeSelection(mockEvent); // assert @@ -186,9 +187,5 @@ function createTreeNodeStub(scenario: { }) { return new TreeNodeStub() .withHierarchy(new HierarchyAccessStub().withIsBranchNode(scenario.isBranch)) - .withState(new TreeNodeStateAccessStub().withCurrent( - new TreeNodeStateDescriptorStub().withCheckState( - scenario.currentState, - ), - )); + .withState(new TreeNodeStateAccessStub().withCurrentCheckState(scenario.currentState)); } diff --git a/tests/unit/shared/Stubs/DelaySchedulerStub.ts b/tests/unit/shared/Stubs/DelaySchedulerStub.ts new file mode 100644 index 00000000..d0983531 --- /dev/null +++ b/tests/unit/shared/Stubs/DelaySchedulerStub.ts @@ -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(); + } +} diff --git a/tests/unit/shared/Stubs/EventSubscriptionCollectionStub.ts b/tests/unit/shared/Stubs/EventSubscriptionCollectionStub.ts index 1039240e..d8deb1e7 100644 --- a/tests/unit/shared/Stubs/EventSubscriptionCollectionStub.ts +++ b/tests/unit/shared/Stubs/EventSubscriptionCollectionStub.ts @@ -44,7 +44,7 @@ export class EventSubscriptionCollectionStub methodName: 'unsubscribeAllAndRegister', args: [subscriptions], }); - this.unsubscribeAll(); - this.register(subscriptions); + // Not calling other methods to avoid registering method calls. + this.subscriptions.splice(0, this.subscriptions.length, ...subscriptions); } } diff --git a/tests/unit/shared/Stubs/HierarchyAccessStub.ts b/tests/unit/shared/Stubs/HierarchyAccessStub.ts index 3a71d4b9..093d524c 100644 --- a/tests/unit/shared/Stubs/HierarchyAccessStub.ts +++ b/tests/unit/shared/Stubs/HierarchyAccessStub.ts @@ -39,4 +39,9 @@ export class HierarchyAccessStub implements HierarchyAccess { this.isBranchNode = value; return this; } + + public withIsLeafNode(value: boolean): this { + this.isLeafNode = value; + return this; + } } diff --git a/tests/unit/shared/Stubs/NodeStateChangeEventArgsStub.ts b/tests/unit/shared/Stubs/NodeStateChangeEventArgsStub.ts new file mode 100644 index 00000000..c23bcb20 --- /dev/null +++ b/tests/unit/shared/Stubs/NodeStateChangeEventArgsStub.ts @@ -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; +} diff --git a/tests/unit/shared/Stubs/NodeStateChangedEventStub.ts b/tests/unit/shared/Stubs/NodeStateChangedEventStub.ts index 69d30fb2..aa24c35c 100644 --- a/tests/unit/shared/Stubs/NodeStateChangedEventStub.ts +++ b/tests/unit/shared/Stubs/NodeStateChangedEventStub.ts @@ -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 { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor'; import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub'; @@ -17,17 +16,4 @@ export class NodeStateChangedEventStub implements NodeStateChangedEvent { 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), - ); - } } diff --git a/tests/unit/shared/Stubs/QueryableNodesStub.ts b/tests/unit/shared/Stubs/QueryableNodesStub.ts index ab9b2b51..3a8a9c43 100644 --- a/tests/unit/shared/Stubs/QueryableNodesStub.ts +++ b/tests/unit/shared/Stubs/QueryableNodesStub.ts @@ -1,10 +1,16 @@ 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 { TreeNodeStub } from './TreeNodeStub'; 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 { throw new Error('Method not implemented.'); diff --git a/tests/unit/shared/Stubs/SingleNodeFocusManagerStub.ts b/tests/unit/shared/Stubs/SingleNodeFocusManagerStub.ts index 2e567882..6467ba5e 100644 --- a/tests/unit/shared/Stubs/SingleNodeFocusManagerStub.ts +++ b/tests/unit/shared/Stubs/SingleNodeFocusManagerStub.ts @@ -3,7 +3,8 @@ import { SingleNodeFocusManager } from '@/presentation/components/Scripts/View/T import { TreeNodeStub } from './TreeNodeStub'; export class SingleNodeFocusManagerStub implements SingleNodeFocusManager { - public currentSingleFocusedNode: TreeNode = new TreeNodeStub(); + public currentSingleFocusedNode: TreeNode = new TreeNodeStub() + .withId(`[${SingleNodeFocusManagerStub.name}] focused-node-stub`); setSingleFocus(): void { /* NOOP */ } } diff --git a/tests/unit/shared/Stubs/TreeNodeCollectionStub.ts b/tests/unit/shared/Stubs/TreeNodeCollectionStub.ts index a6f21dfc..7259a066 100644 --- a/tests/unit/shared/Stubs/TreeNodeCollectionStub.ts +++ b/tests/unit/shared/Stubs/TreeNodeCollectionStub.ts @@ -1,9 +1,10 @@ import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes'; import { TreeNodeCollection } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeCollection'; import { EventSourceStub } from './EventSourceStub'; +import { QueryableNodesStub } from './QueryableNodesStub'; export class TreeNodeCollectionStub implements TreeNodeCollection { - public nodes: QueryableNodes; + public nodes: QueryableNodes = new QueryableNodesStub(); public nodesUpdated = new EventSourceStub(); diff --git a/tests/unit/shared/Stubs/TreeNodeParserStub.ts b/tests/unit/shared/Stubs/TreeNodeParserStub.ts index f4b6b169..d396a045 100644 --- a/tests/unit/shared/Stubs/TreeNodeParserStub.ts +++ b/tests/unit/shared/Stubs/TreeNodeParserStub.ts @@ -20,7 +20,8 @@ export function createTreeNodeParserStub() { if (result !== undefined) { return result.result; } - return data.map(() => new TreeNodeStub()); + return data.map(() => new TreeNodeStub() + .withId(`[${createTreeNodeParserStub.name}] parsed-node-stub`)); }; return { registerScenario, diff --git a/tests/unit/shared/Stubs/TreeNodeStateAccessStub.ts b/tests/unit/shared/Stubs/TreeNodeStateAccessStub.ts index d4e317f7..91f661aa 100644 --- a/tests/unit/shared/Stubs/TreeNodeStateAccessStub.ts +++ b/tests/unit/shared/Stubs/TreeNodeStateAccessStub.ts @@ -1,10 +1,13 @@ 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 { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState'; import { TreeNodeStateDescriptorStub } from './TreeNodeStateDescriptorStub'; import { EventSourceStub } from './EventSourceStub'; import { TreeNodeStateTransactionStub } from './TreeNodeStateTransactionStub'; export class TreeNodeStateAccessStub implements TreeNodeStateAccess { + public isStateModificationRequested = false; + public current: TreeNodeStateDescriptor = new TreeNodeStateDescriptorStub(); public changed: EventSourceStub = new EventSourceStub(); @@ -32,6 +35,7 @@ export class TreeNodeStateAccessStub implements TreeNodeStateAccess { oldState, newState, }); + this.isStateModificationRequested = true; } public triggerStateChangedEvent(event: NodeStateChangedEvent) { @@ -42,4 +46,26 @@ export class TreeNodeStateAccessStub implements TreeNodeStateAccess { this.current = state; 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), + ); } diff --git a/tests/unit/shared/Stubs/TreeNodeStateChangedEmittedEventStub.ts b/tests/unit/shared/Stubs/TreeNodeStateChangedEmittedEventStub.ts new file mode 100644 index 00000000..2546e063 --- /dev/null +++ b/tests/unit/shared/Stubs/TreeNodeStateChangedEmittedEventStub.ts @@ -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), + ); + } +} diff --git a/tests/unit/shared/Stubs/TreeNodeStateDescriptorStub.ts b/tests/unit/shared/Stubs/TreeNodeStateDescriptorStub.ts index 73048f92..340a65a0 100644 --- a/tests/unit/shared/Stubs/TreeNodeStateDescriptorStub.ts +++ b/tests/unit/shared/Stubs/TreeNodeStateDescriptorStub.ts @@ -12,7 +12,7 @@ export class TreeNodeStateDescriptorStub implements TreeNodeStateDescriptor { public isFocused = false; - public withFocusState(isFocused: boolean): this { + public withFocus(isFocused: boolean): this { this.isFocused = isFocused; return this; } @@ -21,4 +21,9 @@ export class TreeNodeStateDescriptorStub implements TreeNodeStateDescriptor { this.checkState = checkState; return this; } + + public withVisibility(isVisible: boolean): this { + this.isVisible = isVisible; + return this; + } } diff --git a/tests/unit/shared/Stubs/TreeNodeStub.ts b/tests/unit/shared/Stubs/TreeNodeStub.ts index 82c80e49..11cbbae8 100644 --- a/tests/unit/shared/Stubs/TreeNodeStub.ts +++ b/tests/unit/shared/Stubs/TreeNodeStub.ts @@ -3,13 +3,24 @@ import { TreeNodeStateAccess } from '@/presentation/components/Scripts/View/Tree import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode'; import { NodeMetadataStub } from './NodeMetadataStub'; import { HierarchyAccessStub } from './HierarchyAccessStub'; +import { TreeNodeStateAccessStub } from './TreeNodeStateAccessStub'; 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 id: string; + public id = 'tree-node-stub-id'; public metadata?: object = new NodeMetadataStub(); diff --git a/tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub.ts b/tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub.ts index 312498c3..8178bdcf 100644 --- a/tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub.ts +++ b/tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub.ts @@ -2,9 +2,11 @@ import { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hook import { EventSubscriptionCollectionStub } from './EventSubscriptionCollectionStub'; export class UseAutoUnsubscribedEventsStub { + public readonly events = new EventSubscriptionCollectionStub(); + public get(): ReturnType { return { - events: new EventSubscriptionCollectionStub(), + events: this.events, }; } } diff --git a/tests/unit/shared/Stubs/UseCurrentTreeNodesStub.ts b/tests/unit/shared/Stubs/UseCurrentTreeNodesStub.ts new file mode 100644 index 00000000..f0a11e1c --- /dev/null +++ b/tests/unit/shared/Stubs/UseCurrentTreeNodesStub.ts @@ -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 | undefined; + + private nodes = shallowRef(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) => { + this.treeWatcher = treeWatcher; + return { + nodes: readonly(this.nodes), + }; + }; + } +} diff --git a/tests/unit/shared/Stubs/UseNodeStateChangeAggregatorStub.ts b/tests/unit/shared/Stubs/UseNodeStateChangeAggregatorStub.ts new file mode 100644 index 00000000..09832d37 --- /dev/null +++ b/tests/unit/shared/Stubs/UseNodeStateChangeAggregatorStub.ts @@ -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 | 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) => { + this.treeWatcher = treeWatcher; + return { + onNodeStateChange: this.onNodeStateChange.bind(this), + }; + }; + } +}