Fix OS switching not working on tree view UI

This commit resolves a rendering bug in the tree view component.
Previously, updating the tree collection prior to node updates led to
rendering errors due to the presence of non-existent nodes in the new
collection.

Changes:

- Implement manual control over the rendering process in tree view. This
  includes clearing the rendering queue and currently rendered nodes
  before updates, aligning the rendering process with the updated
  collection.
- Add Cypress E2E tests to test switching between all operating systems
  and script views, ensuring no uncaught errors and preventing
  regression.
- Replace hardcoded operating system lists in the download URL list view
  with a unified `getSupportedOsList()` method from the application,
  reducing duplication and simplifying future updates.
- Rename `initial-nodes` to `nodes` in `TreeView.vue` to reflect their
  mutable nature.
- Centralize the function for getting operating system names into
  `OperatingSystemNames.ts`, improving reusability in E2E tests.
This commit is contained in:
undergroundwires
2023-12-14 09:51:42 +01:00
parent fe3de498c8
commit 3457fe18cf
13 changed files with 285 additions and 76 deletions

View File

@@ -14,10 +14,11 @@
import { defineComponent, computed } from 'vue'; import { defineComponent, computed } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { getOperatingSystemDisplayName } from '@/presentation/components/Shared/OperatingSystemNames';
import MenuOptionList from './MenuOptionList.vue'; import MenuOptionList from './MenuOptionList.vue';
import MenuOptionListItem from './MenuOptionListItem.vue'; import MenuOptionListItem from './MenuOptionListItem.vue';
interface IOsViewModel { interface OperatingSystemOption {
readonly name: string; readonly name: string;
readonly os: OperatingSystem; readonly os: OperatingSystem;
} }
@@ -31,12 +32,12 @@ export default defineComponent({
const { modifyCurrentContext, currentState } = injectKey((keys) => keys.useCollectionState); const { modifyCurrentContext, currentState } = injectKey((keys) => keys.useCollectionState);
const { application } = injectKey((keys) => keys.useApplication); const { application } = injectKey((keys) => keys.useApplication);
const allOses = computed<ReadonlyArray<IOsViewModel>>( const allOses = computed<ReadonlyArray<OperatingSystemOption>>(
() => application () => application
.getSupportedOsList() .getSupportedOsList()
.map((os) : IOsViewModel => ({ .map((os) : OperatingSystemOption => ({
os, os,
name: renderOsName(os), name: getOperatingSystemDisplayName(os),
})), })),
); );
@@ -57,13 +58,4 @@ export default defineComponent({
}; };
}, },
}); });
function renderOsName(os: OperatingSystem): string {
switch (os) {
case OperatingSystem.Windows: return 'Windows';
case OperatingSystem.macOS: return 'macOS';
case OperatingSystem.Linux: return 'Linux (preview)';
default: throw new RangeError(`Cannot render os name: ${OperatingSystem[os]}`);
}
}
</script> </script>

View File

@@ -5,9 +5,9 @@
'top-padding': hasTopPadding, 'top-padding': hasTopPadding,
}" }"
> >
<template v-if="initialNodes.length"> <template v-if="nodes.length">
<TreeView <TreeView
:initial-nodes="initialNodes" :nodes="nodes"
:selected-leaf-node-ids="selectedScriptNodeIds" :selected-leaf-node-ids="selectedScriptNodeIds"
:latest-filter-event="latestFilterEvent" :latest-filter-event="latestFilterEvent"
@node-state-changed="handleNodeChangedEvent($event)" @node-state-changed="handleNodeChangedEvent($event)"
@@ -61,7 +61,7 @@ export default defineComponent({
} }
return { return {
initialNodes: treeViewInputNodes, nodes: treeViewInputNodes,
selectedScriptNodeIds, selectedScriptNodeIds,
latestFilterEvent, latestFilterEvent,
handleNodeChangedEvent, handleNodeChangedEvent,

View File

@@ -5,12 +5,19 @@ import { ReadOnlyTreeNode } from '../Node/TreeNode';
import { useNodeStateChangeAggregator } from '../UseNodeStateChangeAggregator'; import { useNodeStateChangeAggregator } from '../UseNodeStateChangeAggregator';
import { TreeRoot } from '../TreeRoot/TreeRoot'; import { TreeRoot } from '../TreeRoot/TreeRoot';
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes'; import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { QueryableNodes } from '../TreeRoot/NodeCollection/Query/QueryableNodes';
import { NodeRenderingStrategy } from './Scheduling/NodeRenderingStrategy'; import { NodeRenderingStrategy } from './Scheduling/NodeRenderingStrategy';
import { DelayScheduler } from './DelayScheduler'; import { DelayScheduler } from './DelayScheduler';
import { TimeoutDelayScheduler } from './Scheduling/TimeoutDelayScheduler'; import { TimeoutDelayScheduler } from './Scheduling/TimeoutDelayScheduler';
import { RenderQueueOrderer } from './Ordering/RenderQueueOrderer'; import { RenderQueueOrderer } from './Ordering/RenderQueueOrderer';
import { CollapsedParentOrderer } from './Ordering/CollapsedParentOrderer'; import { CollapsedParentOrderer } from './Ordering/CollapsedParentOrderer';
export interface NodeRenderingControl {
readonly renderingStrategy: NodeRenderingStrategy;
clearRenderingStates(): void;
notifyRenderingUpdates(): void;
}
/** /**
* Renders tree nodes gradually to prevent UI freeze when loading large amounts of nodes. * Renders tree nodes gradually to prevent UI freeze when loading large amounts of nodes.
*/ */
@@ -22,7 +29,7 @@ export function useGradualNodeRendering(
initialBatchSize = 30, initialBatchSize = 30,
subsequentBatchSize = 5, subsequentBatchSize = 5,
orderer: RenderQueueOrderer = new CollapsedParentOrderer(), orderer: RenderQueueOrderer = new CollapsedParentOrderer(),
): NodeRenderingStrategy { ): NodeRenderingControl {
const nodesToRender = new Set<ReadOnlyTreeNode>(); const nodesToRender = new Set<ReadOnlyTreeNode>();
const nodesBeingRendered = shallowRef(new Set<ReadOnlyTreeNode>()); const nodesBeingRendered = shallowRef(new Set<ReadOnlyTreeNode>());
let isRenderingInProgress = false; let isRenderingInProgress = false;
@@ -31,6 +38,10 @@ export function useGradualNodeRendering(
const { onNodeStateChange } = useChangeAggregator(treeRootRef); const { onNodeStateChange } = useChangeAggregator(treeRootRef);
const { nodes } = useTreeNodes(treeRootRef); const { nodes } = useTreeNodes(treeRootRef);
function notifyRenderingUpdates() {
triggerRef(nodesBeingRendered);
}
function updateNodeRenderQueue(node: ReadOnlyTreeNode, isVisible: boolean) { function updateNodeRenderQueue(node: ReadOnlyTreeNode, isVisible: boolean) {
if (isVisible if (isVisible
&& !nodesToRender.has(node) && !nodesToRender.has(node)
@@ -43,16 +54,20 @@ export function useGradualNodeRendering(
} }
if (nodesBeingRendered.value.has(node)) { if (nodesBeingRendered.value.has(node)) {
nodesBeingRendered.value.delete(node); nodesBeingRendered.value.delete(node);
triggerRef(nodesBeingRendered); notifyRenderingUpdates();
} }
} }
} }
watch(nodes, (newNodes) => { function clearRenderingStates() {
nodesToRender.clear(); nodesToRender.clear();
nodesBeingRendered.value.clear(); nodesBeingRendered.value.clear();
}
function initializeAndRenderNodes(newNodes: QueryableNodes) {
clearRenderingStates();
if (!newNodes || newNodes.flattenedNodes.length === 0) { if (!newNodes || newNodes.flattenedNodes.length === 0) {
triggerRef(nodesBeingRendered); notifyRenderingUpdates();
return; return;
} }
newNodes newNodes
@@ -60,6 +75,10 @@ export function useGradualNodeRendering(
.filter((node) => node.state.current.isVisible) .filter((node) => node.state.current.isVisible)
.forEach((node) => nodesToRender.add(node)); .forEach((node) => nodesToRender.add(node));
beginRendering(); beginRendering();
}
watch(nodes, (newNodes) => {
initializeAndRenderNodes(newNodes);
}, { immediate: true }); }, { immediate: true });
onNodeStateChange((change) => { onNodeStateChange((change) => {
@@ -91,7 +110,7 @@ export function useGradualNodeRendering(
nodesToRender.delete(node); nodesToRender.delete(node);
nodesBeingRendered.value.add(node); nodesBeingRendered.value.add(node);
}); });
triggerRef(nodesBeingRendered); notifyRenderingUpdates();
scheduler.scheduleNext( scheduler.scheduleNext(
() => renderNextBatch(subsequentBatchSize), () => renderNextBatch(subsequentBatchSize),
renderingDelayInMs, renderingDelayInMs,
@@ -103,6 +122,10 @@ export function useGradualNodeRendering(
} }
return { return {
shouldRender: shouldNodeBeRendered, renderingStrategy: {
shouldRender: shouldNodeBeRendered,
},
clearRenderingStates,
notifyRenderingUpdates,
}; };
} }

View File

@@ -3,7 +3,7 @@
ref="treeContainerElement" ref="treeContainerElement"
class="tree" class="tree"
> >
<TreeRoot :tree-root="tree" :rendering-strategy="nodeRenderingScheduler"> <TreeRoot :tree-root="tree" :rendering-strategy="renderingStrategy">
<template #default="slotProps"> <template #default="slotProps">
<slot name="node-content" v-bind="slotProps" /> <slot name="node-content" v-bind="slotProps" />
</template> </template>
@@ -15,6 +15,7 @@
import { import {
defineComponent, onMounted, watch, defineComponent, onMounted, watch,
shallowRef, toRef, shallowReadonly, shallowRef, toRef, shallowReadonly,
nextTick,
} from 'vue'; } from 'vue';
import { TreeRootManager } from './TreeRoot/TreeRootManager'; import { TreeRootManager } from './TreeRoot/TreeRootManager';
import TreeRoot from './TreeRoot/TreeRoot.vue'; import TreeRoot from './TreeRoot/TreeRoot.vue';
@@ -27,7 +28,7 @@ import { useLeafNodeCheckedStateUpdater } from './UseLeafNodeCheckedStateUpdater
import { TreeNodeStateChangedEmittedEvent } from './Bindings/TreeNodeStateChangedEmittedEvent'; import { TreeNodeStateChangedEmittedEvent } from './Bindings/TreeNodeStateChangedEmittedEvent';
import { useAutoUpdateParentCheckState } from './UseAutoUpdateParentCheckState'; import { useAutoUpdateParentCheckState } from './UseAutoUpdateParentCheckState';
import { useAutoUpdateChildrenCheckState } from './UseAutoUpdateChildrenCheckState'; import { useAutoUpdateChildrenCheckState } from './UseAutoUpdateChildrenCheckState';
import { useGradualNodeRendering } from './Rendering/UseGradualNodeRendering'; import { useGradualNodeRendering, NodeRenderingControl } from './Rendering/UseGradualNodeRendering';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
export default defineComponent({ export default defineComponent({
@@ -35,7 +36,7 @@ export default defineComponent({
TreeRoot, TreeRoot,
}, },
props: { props: {
initialNodes: { nodes: {
type: Array as PropType<readonly TreeInputNodeData[]>, type: Array as PropType<readonly TreeInputNodeData[]>,
default: () => [], default: () => [],
}, },
@@ -65,7 +66,7 @@ export default defineComponent({
useLeafNodeCheckedStateUpdater(treeRef, toRef(props, 'selectedLeafNodeIds')); useLeafNodeCheckedStateUpdater(treeRef, toRef(props, 'selectedLeafNodeIds'));
useAutoUpdateParentCheckState(treeRef); useAutoUpdateParentCheckState(treeRef);
useAutoUpdateChildrenCheckState(treeRef); useAutoUpdateChildrenCheckState(treeRef);
const nodeRenderingScheduler = useGradualNodeRendering(treeRef); const nodeRenderer = useGradualNodeRendering(treeRef);
const { onNodeStateChange } = useNodeStateChangeAggregator(treeRef); const { onNodeStateChange } = useNodeStateChangeAggregator(treeRef);
@@ -78,18 +79,44 @@ export default defineComponent({
}); });
onMounted(() => { onMounted(() => {
watch(() => props.initialNodes, (nodes) => { watch(() => props.nodes, async (nodes) => {
tree.collection.updateRootNodes(nodes); await forceRerenderNodes(
nodeRenderer,
() => tree.collection.updateRootNodes(nodes),
);
}, { immediate: true }); }, { immediate: true });
}); });
return { return {
treeContainerElement, treeContainerElement,
nodeRenderingScheduler, renderingStrategy: nodeRenderer.renderingStrategy,
tree, tree,
}; };
}, },
}); });
/**
* This function is used to manually trigger a re-render of the tree nodes.
* In Vue, manually controlling the rendering process is typically an anti-pattern,
* as Vue's reactivity system is designed to handle updates efficiently. However,
* in this specific case, it's necessary to ensure the correct order of rendering operations.
* This function first clears the rendering queue and the currently rendered nodes,
* ensuring that UI elements relying on outdated node states are removed. This is needed
* in scenarios where the collection is updated before the nodes, which can lead to errors
* if nodes that no longer exist in the collection are still being rendered.
* Using this function, we ensure a clean state before updating the nodes, aligning with
* the updated collection.
*/
async function forceRerenderNodes(
renderer: NodeRenderingControl,
nodeUpdater: () => void,
) {
renderer.clearRenderingStates();
renderer.notifyRenderingUpdates();
await nextTick();
nodeUpdater();
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -0,0 +1,15 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
export function getOperatingSystemDisplayName(os: OperatingSystem): string {
const displayName = OperatingSystemNames[os];
if (!displayName) {
throw new RangeError(`Unsupported operating system ID: ${os}`);
}
return displayName;
}
const OperatingSystemNames: Partial<Record<OperatingSystem, string>> = {
[OperatingSystem.Windows]: 'Windows',
[OperatingSystem.macOS]: 'macOS',
[OperatingSystem.Linux]: 'Linux (preview)',
};

View File

@@ -24,17 +24,10 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue'; import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import DownloadUrlListItem from './DownloadUrlListItem.vue'; import DownloadUrlListItem from './DownloadUrlListItem.vue';
const supportedOperativeSystems: readonly OperatingSystem[] = [
OperatingSystem.Windows,
OperatingSystem.Linux,
OperatingSystem.macOS,
];
export default defineComponent({ export default defineComponent({
components: { components: {
DownloadUrlListItem, DownloadUrlListItem,
@@ -42,8 +35,12 @@ export default defineComponent({
}, },
setup() { setup() {
const { os: currentOs } = injectKey((keys) => keys.useRuntimeEnvironment); const { os: currentOs } = injectKey((keys) => keys.useRuntimeEnvironment);
const { application } = injectKey((keys) => keys.useApplication);
const supportedOperativeSystems = application.getSupportedOsList();
const supportedDesktops = [ const supportedDesktops = [
...supportedOperativeSystems, ...application.getSupportedOsList(),
].sort((os) => (os === currentOs ? 0 : 1)); ].sort((os) => (os === currentOs ? 0 : 1));
const hasCurrentOsDesktopVersion = currentOs === undefined const hasCurrentOsDesktopVersion = currentOs === undefined

View File

@@ -16,6 +16,7 @@ import {
} from 'vue'; } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { getOperatingSystemDisplayName } from '@/presentation/components/Shared/OperatingSystemNames';
export default defineComponent({ export default defineComponent({
props: { props: {
@@ -33,7 +34,7 @@ export default defineComponent({
}); });
const operatingSystemName = computed<string>(() => { const operatingSystemName = computed<string>(() => {
return getOperatingSystemName(props.operatingSystem); return getOperatingSystemDisplayName(props.operatingSystem);
}); });
const hasCurrentOsDesktopVersion = computed<boolean>(() => { const hasCurrentOsDesktopVersion = computed<boolean>(() => {
@@ -58,20 +59,6 @@ function hasDesktopVersion(os: OperatingSystem): boolean {
|| os === OperatingSystem.Linux || os === OperatingSystem.Linux
|| os === OperatingSystem.macOS; || os === OperatingSystem.macOS;
} }
function getOperatingSystemName(os: OperatingSystem): string {
switch (os) {
case OperatingSystem.Linux:
return 'Linux (preview)';
case OperatingSystem.macOS:
return 'macOS';
case OperatingSystem.Windows:
return 'Windows';
default:
throw new Error(`Unsupported os: ${OperatingSystem[os]}`);
}
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -2,8 +2,8 @@ import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { openCard } from './support/interactions/card'; import { openCard } from './support/interactions/card';
describe('script selection highlighting', () => { describe('script selection highlighting', () => {
// Regression test for a bug where selecting multiple scripts only highlighted the last one.
it('highlights more when multiple scripts are selected', () => { it('highlights more when multiple scripts are selected', () => {
// Regression test for a bug where selecting multiple scripts only highlighted the last one.
cy.visit('/'); cy.visit('/');
selectLastScript(); selectLastScript();
getCurrentHighlightRange((lastScriptHighlightRange) => { getCurrentHighlightRange((lastScriptHighlightRange) => {

View File

@@ -0,0 +1,72 @@
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
import { getEnumValues } from '@/application/Common/Enum';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { getOperatingSystemDisplayName } from '@/presentation/components/Shared/OperatingSystemNames';
describe('operating system selector', () => {
// Regression test for a bug where switching between operating systems caused uncaught exceptions.
describe('allows user to switch between supported operating systems', () => {
getEnumValues(ViewType).forEach((viewType) => {
it(`switches to ${ViewType[viewType]} view successfully`, () => {
// arrange
cy.visit('/');
selectViewType(viewType);
getSupportedOperatingSystemsList().forEach((operatingSystem) => {
// act
selectOperatingSystem(operatingSystem);
// assert
assertExpectedActions();
});
});
});
});
});
function getSupportedOperatingSystemsList() {
/*
Marked: refactor-with-aot-compilation
The operating systems list is hardcoded due to the challenge of loading
the application within Cypress, as its compilation is tightly coupled with Vite.
Ideally, this should dynamically fetch the list from the compiled application.
*/
return [
OperatingSystem.Windows,
OperatingSystem.Linux,
OperatingSystem.macOS,
];
}
function assertExpectedActions() {
/*
Marked: refactor-with-aot-compilation
Assertions are currently hardcoded due to the inability to load the application within
Cypress, as compilation is tightly coupled with Vite. Future refactoring should dynamically
assert the visibility of all actions (e.g., `actions.map((a) => cy.contains(a.title))`)
once the application's compilation process is decoupled from Vite.
*/
cy.contains('Privacy cleanup');
}
function selectOperatingSystem(operatingSystem: OperatingSystem) {
const operatingSystemLabel = getOperatingSystemDisplayName(operatingSystem);
if (!operatingSystemLabel) {
throw new Error(`Label does not exist for operating system: ${OperatingSystem[operatingSystem]}`);
}
cy.log(`Visiting operating system: ${operatingSystemLabel}`);
cy
.contains('span', operatingSystemLabel)
.click();
}
function selectViewType(viewType: ViewType): void {
const viewTypeLabel = ViewTypeLabels[viewType];
cy.log(`Selecting view: ${ViewType[viewType]}`);
cy
.contains('span', viewTypeLabel)
.click();
}
const ViewTypeLabels: Record<ViewType, string> = {
[ViewType.Cards]: 'Cards',
[ViewType.Tree]: 'Tree',
} as const;

View File

@@ -13,6 +13,6 @@
"sourceMap": false, "sourceMap": false,
}, },
"include": [ "include": [
"**/*.ts" "**/*.ts",
] ]
} }

View File

@@ -0,0 +1,24 @@
import {
describe, it, expect,
} from 'vitest';
import { ApplicationFactory } from '@/application/ApplicationFactory';
import { getOperatingSystemDisplayName } from '@/presentation/components/Shared/OperatingSystemNames';
import { OperatingSystem } from '@/domain/OperatingSystem';
describe('OperatingSystemNames', () => {
describe('getOperatingSystemDisplayName', () => {
describe('retrieving display names for supported operating systems', async () => {
// arrange
const application = await ApplicationFactory.Current.getApp();
const supportedOperatingSystems = application.getSupportedOsList();
supportedOperatingSystems.forEach((supportedOperatingSystem) => {
it(`should return a non-empty name for ${OperatingSystem[supportedOperatingSystem]}`, () => {
// act
const displayName = getOperatingSystemDisplayName(supportedOperatingSystem);
// assert
expect(displayName).to.have.length.greaterThanOrEqual(1);
});
});
});
});
});

View File

@@ -9,23 +9,43 @@ import { provideDependencies } from '@/presentation/bootstrapping/DependencyProv
import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationContextStub'; import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationContextStub';
describe('TreeView', () => { describe('TreeView', () => {
it('should render all provided root nodes correctly', async () => { it('renders all provided root nodes correctly', async () => {
// arrange // arrange
const nodes = createSampleNodes(); const nodes = createSampleNodes();
const wrapper = createTreeViewWrapper(nodes); const wrapper = createTreeViewWrapper(nodes);
// act // act
await waitForStableDom(wrapper.element); await waitForStableDom(wrapper.element);
// assert // assert
const expectedTotalRootNodes = nodes.length; const expectedTotalRootNodes = nodes.length;
expect(wrapper.findAll('.node').length).to.equal(expectedTotalRootNodes, wrapper.html()); expect(wrapper.findAll('.node').length).to.equal(expectedTotalRootNodes, wrapper.html());
const rootNodeTexts = nodes.map((node) => node.data.label); const rootNodeTexts = nodes.map((node) => (node.data as TreeInputMetadata).label);
rootNodeTexts.forEach((label) => { rootNodeTexts.forEach((label) => {
expect(wrapper.text()).to.include(label); expect(wrapper.text()).to.include(label);
}); });
}); });
// Regression test for a bug where updating the nodes prop caused uncaught exceptions.
it('updates nodes correctly when props change', async () => {
// arrange
const firstNodeLabel = 'Node 1';
const secondNodeLabel = 'Node 2';
const initialNodes: TreeInputNodeDataWithMetadata[] = [{ id: 'node1', data: { label: firstNodeLabel } }];
const updatedNodes: TreeInputNodeDataWithMetadata[] = [{ id: 'node2', data: { label: secondNodeLabel } }];
const wrapper = createTreeViewWrapper(initialNodes);
// act
await wrapper.setProps({ nodes: updatedNodes });
await waitForStableDom(wrapper.element);
// assert
expect(wrapper.text()).toContain(secondNodeLabel);
expect(wrapper.text()).not.toContain(firstNodeLabel);
});
}); });
function createTreeViewWrapper(initialNodeData: readonly TreeInputNodeData[]) { function createTreeViewWrapper(initialNodeData: readonly TreeInputNodeDataWithMetadata[]) {
return mount(defineComponent({ return mount(defineComponent({
components: { components: {
TreeView, TreeView,
@@ -33,26 +53,34 @@ function createTreeViewWrapper(initialNodeData: readonly TreeInputNodeData[]) {
setup() { setup() {
provideDependencies(new ApplicationContextStub()); provideDependencies(new ApplicationContextStub());
const initialNodes = shallowRef(initialNodeData); const nodes = shallowRef(initialNodeData);
const selectedLeafNodeIds = shallowRef<readonly string[]>([]); const selectedLeafNodeIds = shallowRef<readonly string[]>([]);
return { return {
initialNodes, nodes,
selectedLeafNodeIds, selectedLeafNodeIds,
}; };
}, },
template: ` template: `
<TreeView <TreeView
:initialNodes="initialNodes" :nodes="nodes"
:selectedLeafNodeIds="selectedLeafNodeIds" :selectedLeafNodeIds="selectedLeafNodeIds"
> >
<template v-slot:node-content="{ nodeMetadata }"> <template v-slot:node-content="{ nodeMetadata }">
{{ nodeMetadata.label }} {{ nodeMetadata.label }}
</template> </template>
</TreeView>`, </TreeView>
`,
})); }));
} }
function createSampleNodes() { interface TreeInputMetadata {
readonly label: string;
}
type TreeInputNodeDataWithMetadata = TreeInputNodeData & { readonly data?: TreeInputMetadata };
function createSampleNodes(): TreeInputNodeDataWithMetadata[] {
return [ return [
{ {
id: 'root1', id: 'root1',
@@ -93,7 +121,7 @@ function createSampleNodes() {
function waitForStableDom(rootElement, timeout = 3000, interval = 200): Promise<void> { function waitForStableDom(rootElement, timeout = 3000, interval = 200): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let lastTimeoutId; let lastTimeoutId: ReturnType<typeof setTimeout>;
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
if (lastTimeoutId) { if (lastTimeoutId) {
clearTimeout(lastTimeoutId); clearTimeout(lastTimeoutId);

View File

@@ -1,5 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { type Ref, shallowRef } from 'vue'; import {
type Ref, shallowRef, watch, nextTick,
} from 'vue';
import { useGradualNodeRendering } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering'; import { useGradualNodeRendering } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/UseGradualNodeRendering';
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot'; import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
import { TreeRootStub } from '@tests/unit/shared/Stubs/TreeRootStub'; import { TreeRootStub } from '@tests/unit/shared/Stubs/TreeRootStub';
@@ -70,9 +72,9 @@ describe('useGradualNodeRendering', () => {
.withOldState(new TreeNodeStateDescriptorStub().withVisibility(oldVisibilityState)) .withOldState(new TreeNodeStateDescriptorStub().withVisibility(oldVisibilityState))
.withNewState(new TreeNodeStateDescriptorStub().withVisibility(newVisibilityState)); .withNewState(new TreeNodeStateDescriptorStub().withVisibility(newVisibilityState));
// act // act
const strategy = builder.call(); const { renderingStrategy } = builder.call();
aggregatorStub.notifyChange(change); aggregatorStub.notifyChange(change);
const actualRenderStatus = strategy.shouldRender(node); const actualRenderStatus = renderingStrategy.shouldRender(node);
// assert // assert
expect(actualRenderStatus).to.equal(expectedRenderStatus); expect(actualRenderStatus).to.equal(expectedRenderStatus);
}); });
@@ -186,11 +188,11 @@ describe('useGradualNodeRendering', () => {
.withSubsequentBatchSize(subsequentBatchSize) .withSubsequentBatchSize(subsequentBatchSize)
.withDelayScheduler(delaySchedulerStub); .withDelayScheduler(delaySchedulerStub);
// act // act
const strategy = builder.call(); const { renderingStrategy } = builder.call();
Array.from({ length: schedulerTicks }).forEach( Array.from({ length: schedulerTicks }).forEach(
() => delaySchedulerStub.runNextScheduled(), () => delaySchedulerStub.runNextScheduled(),
); );
const actualRenderStatuses = nodes.map((node) => strategy.shouldRender(node)); const actualRenderStatuses = nodes.map((node) => renderingStrategy.shouldRender(node));
// expect // expect
expect(actualRenderStatuses).to.deep.equal(expectedRenderStatuses); expect(actualRenderStatuses).to.deep.equal(expectedRenderStatuses);
}); });
@@ -218,9 +220,9 @@ describe('useGradualNodeRendering', () => {
const actualOrder = new Set<ReadOnlyTreeNode>(); const actualOrder = new Set<ReadOnlyTreeNode>();
// act // act
ordererStub.orderNodes = () => expectedNodes[0]; ordererStub.orderNodes = () => expectedNodes[0];
const strategy = builder.call(); const { renderingStrategy } = builder.call();
const updateOrder = () => allNodes const updateOrder = () => allNodes
.filter((node) => strategy.shouldRender(node)) .filter((node) => renderingStrategy.shouldRender(node))
.forEach((node) => actualOrder.add(node)); .forEach((node) => actualOrder.add(node));
updateOrder(); updateOrder();
for (let i = 1; i < expectedNodes.length; i++) { for (let i = 1; i < expectedNodes.length; i++) {
@@ -247,6 +249,48 @@ describe('useGradualNodeRendering', () => {
// assert // assert
expect(delaySchedulerStub.nextCallback).toBeUndefined(); expect(delaySchedulerStub.nextCallback).toBeUndefined();
}); });
describe('clearRenderingStates', () => {
it('clears all nodes from rendering states', () => {
// arrange
const nodesStub = [new TreeNodeStub(), new TreeNodeStub()];
const rendering = new UseGradualNodeRenderingBuilder()
.withCurrentTreeNodes(new UseCurrentTreeNodesStub()
.withQueryableNodes(new QueryableNodesStub().withFlattenedNodes(nodesStub)))
.call();
// act
rendering.clearRenderingStates();
// assert
const isAnyRendered = nodesStub
.map((node) => rendering.renderingStrategy.shouldRender(node))
.some(Boolean);
expect(isAnyRendered).to.equal(false);
});
});
describe('notifyRenderingUpdates', () => {
it('triggers Vue reactivity update', async () => {
// arrange
const nodes = createNodesWithVisibility(true, 1);
const rendering = new UseGradualNodeRenderingBuilder()
.withCurrentTreeNodes(new UseCurrentTreeNodesStub()
.withQueryableNodes(new QueryableNodesStub().withFlattenedNodes(nodes)))
.withInitialBatchSize(nodes.length)
.call();
let isVueReactivityTriggered = false;
watch(() => rendering.renderingStrategy.shouldRender(nodes[0]), () => {
isVueReactivityTriggered = true;
});
// act
rendering.clearRenderingStates();
rendering.notifyRenderingUpdates();
await nextTick();
// assert
expect(isVueReactivityTriggered).toEqual(true);
});
});
}); });
function createNodesWithVisibility( function createNodesWithVisibility(