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