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