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:
undergroundwires
2023-09-24 20:34:47 +02:00
parent 0303ef2fd9
commit 8f188acd3c
33 changed files with 1606 additions and 175 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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