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:
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TimeFunctions, TimeoutDelayScheduler } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/TimeoutDelayScheduler';
|
||||
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
|
||||
|
||||
describe('TimeoutDelayScheduler', () => {
|
||||
describe('scheduleNext', () => {
|
||||
describe('when setting a new timeout', () => {
|
||||
it('sets callback correctly', () => {
|
||||
// arrange
|
||||
const timerStub = new TimeFunctionsStub();
|
||||
const scheduler = new TimeoutDelayScheduler(timerStub);
|
||||
const expectedCallback = () => { /* NO OP */ };
|
||||
// act
|
||||
scheduler.scheduleNext(expectedCallback, 3131);
|
||||
// assert
|
||||
const setTimeoutCalls = timerStub.callHistory.filter((c) => c.methodName === 'setTimeout');
|
||||
expect(setTimeoutCalls).to.have.lengthOf(1);
|
||||
const [actualCallback] = setTimeoutCalls[0].args;
|
||||
expect(actualCallback).toBe(expectedCallback);
|
||||
});
|
||||
it('sets delay correctly', () => {
|
||||
// arrange
|
||||
const timerStub = new TimeFunctionsStub();
|
||||
const scheduler = new TimeoutDelayScheduler(timerStub);
|
||||
const expectedDelay = 100;
|
||||
// act
|
||||
scheduler.scheduleNext(() => {}, expectedDelay);
|
||||
// assert
|
||||
const setTimeoutCalls = timerStub.callHistory.filter((c) => c.methodName === 'setTimeout');
|
||||
expect(setTimeoutCalls).to.have.lengthOf(1);
|
||||
const [,actualDelay] = setTimeoutCalls[0].args;
|
||||
expect(actualDelay).toBe(expectedDelay);
|
||||
});
|
||||
it('does not clear any timeout if none was previously set', () => {
|
||||
// arrange
|
||||
const timerStub = new TimeFunctionsStub();
|
||||
const scheduler = new TimeoutDelayScheduler(timerStub);
|
||||
// act
|
||||
scheduler.scheduleNext(() => {}, 100);
|
||||
// assert
|
||||
const clearTimeoutCalls = timerStub.callHistory.filter((c) => c.methodName === 'clearTimeout');
|
||||
expect(clearTimeoutCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
describe('when rescheduling a timeout', () => {
|
||||
it('clears the previous timeout', () => {
|
||||
// arrange
|
||||
const timerStub = new TimeFunctionsStub();
|
||||
const scheduler = new TimeoutDelayScheduler(timerStub);
|
||||
const idOfFirstSetTimeoutCall = 1;
|
||||
// act
|
||||
scheduler.scheduleNext(() => {}, 100);
|
||||
scheduler.scheduleNext(() => {}, 200);
|
||||
// assert
|
||||
const setTimeoutCalls = timerStub.callHistory.filter((c) => c.methodName === 'setTimeout');
|
||||
expect(setTimeoutCalls.length).toBe(2);
|
||||
const clearTimeoutCalls = timerStub.callHistory.filter((c) => c.methodName === 'clearTimeout');
|
||||
expect(clearTimeoutCalls.length).toBe(1);
|
||||
const [actualId] = clearTimeoutCalls[0].args;
|
||||
expect(actualId).toBe(idOfFirstSetTimeoutCall);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class TimeFunctionsStub
|
||||
extends StubWithObservableMethodCalls<TimeFunctions>
|
||||
implements TimeFunctions {
|
||||
public clearTimeout(id: ReturnType<typeof setTimeout>): void {
|
||||
this.registerMethodCall({
|
||||
methodName: 'clearTimeout',
|
||||
args: [id],
|
||||
});
|
||||
}
|
||||
|
||||
public setTimeout(callback: () => void, delayInMs: number): ReturnType<typeof setTimeout> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'setTimeout',
|
||||
args: [callback, delayInMs],
|
||||
});
|
||||
return this.callHistory.filter((c) => c.methodName === 'setTimeout').length as unknown as ReturnType<typeof setTimeout>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { WatchSource } from 'vue';
|
||||
import { useGradualNodeRendering } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering';
|
||||
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
|
||||
import { TreeRootStub } from '@tests/unit/shared/Stubs/TreeRootStub';
|
||||
import { UseNodeStateChangeAggregatorStub } from '@tests/unit/shared/Stubs/UseNodeStateChangeAggregatorStub';
|
||||
import { UseCurrentTreeNodesStub } from '@tests/unit/shared/Stubs/UseCurrentTreeNodesStub';
|
||||
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||
import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
|
||||
import { QueryableNodesStub } from '@tests/unit/shared/Stubs/QueryableNodesStub';
|
||||
import { NodeStateChangeEventArgsStub } from '@tests/unit/shared/Stubs/NodeStateChangeEventArgsStub';
|
||||
import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub';
|
||||
import { DelaySchedulerStub } from '@tests/unit/shared/Stubs/DelaySchedulerStub';
|
||||
import { DelayScheduler } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/DelayScheduler';
|
||||
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
|
||||
|
||||
describe('useGradualNodeRendering', () => {
|
||||
it('watches nodes on specified tree', () => {
|
||||
// arrange
|
||||
const expectedWatcher = () => new TreeRootStub();
|
||||
const currentTreeNodesStub = new UseCurrentTreeNodesStub();
|
||||
const builder = new UseGradualNodeRenderingBuilder()
|
||||
.withCurrentTreeNodes(currentTreeNodesStub)
|
||||
.withTreeWatcher(expectedWatcher);
|
||||
// act
|
||||
builder.call();
|
||||
// assert
|
||||
const actualWatcher = currentTreeNodesStub.treeWatcher;
|
||||
expect(actualWatcher).to.equal(expectedWatcher);
|
||||
});
|
||||
describe('shouldRender', () => {
|
||||
describe('on visibility toggle', () => {
|
||||
const scenarios: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly oldVisibilityState: boolean;
|
||||
readonly newVisibilityState: boolean;
|
||||
readonly expectedRenderStatus: boolean;
|
||||
}> = [
|
||||
{
|
||||
description: 'renders node when made visible',
|
||||
oldVisibilityState: false,
|
||||
newVisibilityState: true,
|
||||
expectedRenderStatus: true,
|
||||
},
|
||||
{
|
||||
description: 'does not render node when hidden',
|
||||
oldVisibilityState: true,
|
||||
newVisibilityState: false,
|
||||
expectedRenderStatus: false,
|
||||
},
|
||||
];
|
||||
scenarios.forEach(({
|
||||
description, newVisibilityState, oldVisibilityState, expectedRenderStatus,
|
||||
}) => {
|
||||
it(description, () => {
|
||||
// arrange
|
||||
const node = createNodeWithVisibility(oldVisibilityState);
|
||||
const nodesStub = new UseCurrentTreeNodesStub()
|
||||
.withQueryableNodes(new QueryableNodesStub().withFlattenedNodes([node]));
|
||||
const aggregatorStub = new UseNodeStateChangeAggregatorStub();
|
||||
const delaySchedulerStub = new DelaySchedulerStub();
|
||||
const builder = new UseGradualNodeRenderingBuilder()
|
||||
.withCurrentTreeNodes(nodesStub)
|
||||
.withChangeAggregator(aggregatorStub)
|
||||
.withDelayScheduler(delaySchedulerStub);
|
||||
const change = new NodeStateChangeEventArgsStub()
|
||||
.withNode(node)
|
||||
.withOldState(new TreeNodeStateDescriptorStub().withVisibility(oldVisibilityState))
|
||||
.withNewState(new TreeNodeStateDescriptorStub().withVisibility(newVisibilityState));
|
||||
// act
|
||||
const strategy = builder.call();
|
||||
aggregatorStub.notifyChange(change);
|
||||
const actualRenderStatus = strategy.shouldRender(node);
|
||||
// assert
|
||||
expect(actualRenderStatus).to.equal(expectedRenderStatus);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('on initial nodes', () => {
|
||||
const scenarios: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly schedulerTicks: number;
|
||||
readonly initialBatchSize: number;
|
||||
readonly subsequentBatchSize: number;
|
||||
readonly nodes: readonly TreeNode[];
|
||||
readonly expectedRenderStatuses: readonly number[],
|
||||
}> = [
|
||||
(() => {
|
||||
const totalNodes = 10;
|
||||
return {
|
||||
description: 'does not render if all nodes are hidden',
|
||||
schedulerTicks: 0,
|
||||
initialBatchSize: 5,
|
||||
subsequentBatchSize: 2,
|
||||
nodes: createNodesWithVisibility(false, totalNodes),
|
||||
expectedRenderStatuses: new Array(totalNodes).fill(false),
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const expectedRenderStatuses = [
|
||||
false, false, true, true, false,
|
||||
];
|
||||
const nodes = expectedRenderStatuses.map((status) => createNodeWithVisibility(status));
|
||||
return {
|
||||
description: 'renders only visible nodes',
|
||||
schedulerTicks: 0,
|
||||
initialBatchSize: nodes.length,
|
||||
subsequentBatchSize: 2,
|
||||
nodes,
|
||||
expectedRenderStatuses,
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const initialBatchSize = 5;
|
||||
return {
|
||||
description: 'renders initial nodes immediately',
|
||||
schedulerTicks: 0,
|
||||
initialBatchSize,
|
||||
subsequentBatchSize: 2,
|
||||
nodes: createNodesWithVisibility(true, initialBatchSize),
|
||||
expectedRenderStatuses: new Array(initialBatchSize).fill(true),
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const initialBatchSize = 5;
|
||||
const subsequentBatchSize = 2;
|
||||
const totalNodes = initialBatchSize + subsequentBatchSize * 2;
|
||||
return {
|
||||
description: 'does not render subsequent node batches immediately',
|
||||
schedulerTicks: 0,
|
||||
initialBatchSize,
|
||||
subsequentBatchSize,
|
||||
nodes: createNodesWithVisibility(true, totalNodes),
|
||||
expectedRenderStatuses: [
|
||||
...new Array(initialBatchSize).fill(true),
|
||||
...new Array(totalNodes - initialBatchSize).fill(false),
|
||||
],
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const initialBatchSize = 5;
|
||||
const subsequentBatchSize = 2;
|
||||
const totalNodes = initialBatchSize + subsequentBatchSize * 2;
|
||||
return {
|
||||
description: 'eventually renders next subsequent node batch',
|
||||
schedulerTicks: 1,
|
||||
initialBatchSize,
|
||||
subsequentBatchSize,
|
||||
nodes: createNodesWithVisibility(true, totalNodes),
|
||||
expectedRenderStatuses: [
|
||||
...new Array(initialBatchSize).fill(true),
|
||||
...new Array(subsequentBatchSize).fill(true), // first batch
|
||||
...new Array(subsequentBatchSize).fill(false), // second batch
|
||||
],
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const initialBatchSize = 5;
|
||||
const totalSubsequentBatches = 2;
|
||||
const subsequentBatchSize = 2;
|
||||
const totalNodes = initialBatchSize + subsequentBatchSize * totalSubsequentBatches;
|
||||
return {
|
||||
description: 'eventually renders all subsequent node batches',
|
||||
schedulerTicks: subsequentBatchSize,
|
||||
initialBatchSize,
|
||||
subsequentBatchSize,
|
||||
nodes: createNodesWithVisibility(true, totalNodes),
|
||||
expectedRenderStatuses: new Array(totalNodes).fill(true),
|
||||
};
|
||||
})(),
|
||||
];
|
||||
scenarios.forEach(({
|
||||
description, nodes, schedulerTicks, initialBatchSize,
|
||||
subsequentBatchSize, expectedRenderStatuses,
|
||||
}) => {
|
||||
it(description, () => {
|
||||
// arrange
|
||||
const delaySchedulerStub = new DelaySchedulerStub();
|
||||
const nodesStub = new UseCurrentTreeNodesStub()
|
||||
.withQueryableNodes(new QueryableNodesStub().withFlattenedNodes(nodes));
|
||||
const builder = new UseGradualNodeRenderingBuilder()
|
||||
.withCurrentTreeNodes(nodesStub)
|
||||
.withInitialBatchSize(initialBatchSize)
|
||||
.withSubsequentBatchSize(subsequentBatchSize)
|
||||
.withDelayScheduler(delaySchedulerStub);
|
||||
// act
|
||||
const strategy = builder.call();
|
||||
Array.from({ length: schedulerTicks }).forEach(
|
||||
() => delaySchedulerStub.runNextScheduled(),
|
||||
);
|
||||
const actualRenderStatuses = nodes.map((node) => strategy.shouldRender(node));
|
||||
// expect
|
||||
expect(actualRenderStatuses).to.deep.equal(expectedRenderStatuses);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
it('skips scheduling when no nodes to render', () => {
|
||||
// arrange
|
||||
const nodes = [];
|
||||
const nodesStub = new UseCurrentTreeNodesStub()
|
||||
.withQueryableNodes(new QueryableNodesStub().withFlattenedNodes(nodes));
|
||||
const delaySchedulerStub = new DelaySchedulerStub();
|
||||
const builder = new UseGradualNodeRenderingBuilder()
|
||||
.withCurrentTreeNodes(nodesStub)
|
||||
.withDelayScheduler(delaySchedulerStub);
|
||||
// act
|
||||
builder.call();
|
||||
// assert
|
||||
expect(delaySchedulerStub.nextCallback).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function createNodesWithVisibility(
|
||||
isVisible: boolean,
|
||||
count: number,
|
||||
): readonly TreeNodeStub[] {
|
||||
return Array.from({ length: count })
|
||||
.map(() => createNodeWithVisibility(isVisible));
|
||||
}
|
||||
|
||||
function createNodeWithVisibility(
|
||||
isVisible: boolean,
|
||||
): TreeNodeStub {
|
||||
return new TreeNodeStub()
|
||||
.withState(
|
||||
new TreeNodeStateAccessStub().withCurrentVisibility(isVisible),
|
||||
);
|
||||
}
|
||||
|
||||
class UseGradualNodeRenderingBuilder {
|
||||
private changeAggregator = new UseNodeStateChangeAggregatorStub();
|
||||
|
||||
private treeWatcher: WatchSource<TreeRoot | undefined> = () => new TreeRootStub();
|
||||
|
||||
private currentTreeNodes = new UseCurrentTreeNodesStub();
|
||||
|
||||
private delayScheduler: DelayScheduler = new DelaySchedulerStub();
|
||||
|
||||
private initialBatchSize = 5;
|
||||
|
||||
private subsequentBatchSize = 3;
|
||||
|
||||
public withChangeAggregator(changeAggregator: UseNodeStateChangeAggregatorStub): this {
|
||||
this.changeAggregator = changeAggregator;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCurrentTreeNodes(treeNodes: UseCurrentTreeNodesStub): this {
|
||||
this.currentTreeNodes = treeNodes;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withTreeWatcher(treeWatcher: WatchSource<TreeRoot | undefined>): this {
|
||||
this.treeWatcher = treeWatcher;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withDelayScheduler(delayScheduler: DelayScheduler): this {
|
||||
this.delayScheduler = delayScheduler;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withInitialBatchSize(initialBatchSize: number): this {
|
||||
this.initialBatchSize = initialBatchSize;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withSubsequentBatchSize(subsequentBatchSize: number): this {
|
||||
this.subsequentBatchSize = subsequentBatchSize;
|
||||
return this;
|
||||
}
|
||||
|
||||
public call(): ReturnType<typeof useGradualNodeRendering> {
|
||||
return useGradualNodeRendering(
|
||||
this.treeWatcher,
|
||||
this.changeAggregator.get(),
|
||||
this.currentTreeNodes.get(),
|
||||
this.delayScheduler,
|
||||
this.initialBatchSize,
|
||||
this.subsequentBatchSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user