#140 tests for search panel component

This commit is contained in:
lana-k
2026-10-11 23:03:51 +02:00
parent 239e2667d4
commit aff0c2c81f
3 changed files with 272 additions and 37 deletions
+26 -1
View File
@@ -244,7 +244,6 @@
v-model:nodesMatchingSearchQuery="nodesMatchingSearchQuery" v-model:nodesMatchingSearchQuery="nodesMatchingSearchQuery"
v-model:focusedFoundNodeId="focusedFoundNodeId" v-model:focusedFoundNodeId="focusedFoundNodeId"
:graph="graph" :graph="graph"
:renderer="renderer"
class="search-panel" class="search-panel"
/> />
</div> </div>
@@ -517,6 +516,23 @@ export default {
this.renderer.scheduleRender() this.renderer.scheduleRender()
} }
} }
},
nodesMatchingSearchQuery() {
if (this.renderer) {
this.renderer.refresh({
skipIndexation: true
})
}
},
focusedFoundNodeId() {
if (this.renderer) {
this.renderer.refresh({
skipIndexation: true
})
}
if (this.focusedFoundNodeId) {
this.moveCameraToNode(this.focusedFoundNodeId)
}
} }
}, },
mounted() { mounted() {
@@ -839,6 +855,15 @@ export default {
return drawOnCanvas(this.renderer, { return drawOnCanvas(this.renderer, {
backgroundColor: this.settings.style.backgroundColor backgroundColor: this.settings.style.backgroundColor
}) })
},
moveCameraToNode(nodeId) {
if (!this.graph) {
return
}
const nodePosition = this.renderer.getNodeDisplayData(nodeId)
this.renderer.getCamera().animate(nodePosition, {
duration: 500
})
} }
} }
} }
+10 -36
View File
@@ -5,6 +5,7 @@
:title="panelOpen ? 'Close search panel' : 'Open search panel'" :title="panelOpen ? 'Close search panel' : 'Open search panel'"
variant="secondary" variant="secondary"
:class="{ 'close-search-panel': panelOpen }" :class="{ 'close-search-panel': panelOpen }"
data-test="toggle-search-panel-visibility-btn"
@click="panelOpen = !panelOpen" @click="panelOpen = !panelOpen"
/> />
<div v-show="panelOpen" class="graph-search-panel--search-controls"> <div v-show="panelOpen" class="graph-search-panel--search-controls">
@@ -21,12 +22,14 @@
icon="myicon myicon-match-case" icon="myicon myicon-match-case"
title="Match case" title="Match case"
:active="matchCase" :active="matchCase"
data-test="match-case-btn"
@click="matchCase = !matchCase" @click="matchCase = !matchCase"
/> />
<IconButton <IconButton
icon="myicon myicon-use-reg-exp" icon="myicon myicon-use-reg-exp"
title="Use regular expression" title="Use regular expression"
:active="useRegExp" :active="useRegExp"
data-test="use-reg-exp-btn"
@click="useRegExp = !useRegExp" @click="useRegExp = !useRegExp"
/> />
@@ -42,7 +45,7 @@
v-if=" v-if="
nodesMatchingSearchQuery?.length > 0 && currentFoundNodeIndex !== -1 nodesMatchingSearchQuery?.length > 0 && currentFoundNodeIndex !== -1
" "
>{{ currentFoundNodeIndex + 1 }} / >{{ currentFoundNodeIndex + 1 }} of
{{ nodesMatchingSearchQuery.length }} {{ nodesMatchingSearchQuery.length }}
</span> </span>
@@ -50,6 +53,7 @@
v-show=" v-show="
nodesMatchingSearchQuery && nodesMatchingSearchQuery.length === 0 nodesMatchingSearchQuery && nodesMatchingSearchQuery.length === 0
" "
data-test="no-results-text"
> >
No results No results
</span> </span>
@@ -58,6 +62,7 @@
icon="myicon myicon-arrow" icon="myicon myicon-arrow"
title="Previous match" title="Previous match"
:disabled="arrowsDisabled" :disabled="arrowsDisabled"
data-test="previous-match-btn"
@click="toNextFoundNode(-1)" @click="toNextFoundNode(-1)"
/> />
@@ -66,6 +71,7 @@
title="Next match" title="Next match"
:disabled="arrowsDisabled" :disabled="arrowsDisabled"
class="next-match-btn" class="next-match-btn"
data-test="next-match-btn"
@click="toNextFoundNode(1)" @click="toNextFoundNode(1)"
/> />
</div> </div>
@@ -92,12 +98,7 @@ export default {
TextField, TextField,
IconButton IconButton
}, },
props: [ props: ['graph', 'nodesMatchingSearchQuery', 'focusedFoundNodeId'],
'graph',
'renderer',
'nodesMatchingSearchQuery',
'focusedFoundNodeId'
],
data() { data() {
return { return {
searchQuery: '', searchQuery: '',
@@ -136,7 +137,7 @@ export default {
}).format(count) }).format(count)
: count : count
return `${formattedCount} nodes found` return `${formattedCount} ${count === 1 ? 'node' : 'nodes'} found`
} }
}, },
watch: { watch: {
@@ -150,9 +151,7 @@ export default {
}, },
matchCase() { matchCase() {
this.search() this.search()
}, }
nodesMatchingSearchQuery: 'refresh',
focusedFoundNodeId: 'refresh'
}, },
methods: { methods: {
onSearchInput: time.debounce(function () { onSearchInput: time.debounce(function () {
@@ -179,9 +178,6 @@ export default {
}) })
}, },
toNextFoundNode(direction = 1) { toNextFoundNode(direction = 1) {
if (this.nodesMatchingSearchQuery.length === 0) {
return
}
this.currentFoundNodeIndex = this.currentFoundNodeIndex =
(this.currentFoundNodeIndex + (this.currentFoundNodeIndex +
direction + direction +
@@ -189,28 +185,6 @@ export default {
this.nodesMatchingSearchQuery.length this.nodesMatchingSearchQuery.length
const nodeId = this.nodesMatchingSearchQuery[this.currentFoundNodeIndex] const nodeId = this.nodesMatchingSearchQuery[this.currentFoundNodeIndex]
this.$emit('update:focusedFoundNodeId', nodeId) this.$emit('update:focusedFoundNodeId', nodeId)
if (this.renderer) {
this.renderer.refresh({
skipIndexation: true
})
}
this.moveCameraToNode(nodeId)
},
moveCameraToNode(nodeId) {
if (!this.graph) {
return
}
const nodePosition = this.renderer.getNodeDisplayData(nodeId)
this.renderer.getCamera().animate(nodePosition, {
duration: 500
})
},
refresh() {
if (this.renderer) {
this.renderer.refresh({
skipIndexation: true
})
}
} }
} }
} }
@@ -0,0 +1,236 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import Graph from 'graphology'
import GraphSearchPanel from '@/components/Graph/GraphSearchPanel.vue'
import sinon from 'sinon'
import { nextTick } from 'vue'
describe('GraphSearchPanel.vue', () => {
const buildGraph = () => {
const graph = new Graph()
graph.addNode('node-1', { label: 'Alpha' })
graph.addNode('node-2', { label: 'Beta' })
graph.addNode('node-3', { label: 'Gamma' })
graph.addNode('node-4', { label: 'Foo' })
return graph
}
it('matches labels and emits node ids for navigation', async () => {
const clock = sinon.useFakeTimers()
const wrapper = mount(GraphSearchPanel, {
props: {
graph: buildGraph(),
nodesMatchingSearchQuery: null,
focusedFoundNodeId: null,
'onUpdate:nodesMatchingSearchQuery': nodes => {
wrapper.setProps({ nodesMatchingSearchQuery: nodes })
},
'onUpdate:focusedFoundNodeId': nodeId => {
wrapper.setProps({ focusedFoundNodeId: nodeId })
}
}
})
await wrapper.find('.query-input input').setValue('a')
clock.tick(500)
expect(wrapper.emitted('update:nodesMatchingSearchQuery')[0][0]).to.eql([
'node-1',
'node-2',
'node-3'
])
expect(wrapper.emitted('update:focusedFoundNodeId')[0][0]).to.equal(null)
await nextTick()
expect(wrapper.text()).to.contain('3 nodes found')
await wrapper.find('[data-test="next-match-btn"]').trigger('click')
expect(wrapper.emitted('update:focusedFoundNodeId')[1][0]).to.equal(
'node-1'
)
await nextTick()
expect(wrapper.text()).to.contain('1 of 3')
await wrapper.find('[data-test="next-match-btn"]').trigger('click')
expect(wrapper.emitted('update:focusedFoundNodeId')[2][0]).to.equal(
'node-2'
)
await nextTick()
expect(wrapper.text()).to.contain('2 of 3')
await wrapper.find('[data-test="previous-match-btn"]').trigger('click')
expect(wrapper.emitted('update:focusedFoundNodeId')[3][0]).to.equal(
'node-1'
)
await nextTick()
expect(wrapper.text()).to.contain('1 of 3')
await wrapper.find('[data-test="previous-match-btn"]').trigger('click')
expect(wrapper.emitted('update:focusedFoundNodeId')[4][0]).to.equal(
'node-3'
)
await nextTick()
expect(wrapper.text()).to.contain('3 of 3')
await wrapper.find('[data-test="next-match-btn"]').trigger('click')
expect(wrapper.emitted('update:focusedFoundNodeId')[5][0]).to.equal(
'node-1'
)
await nextTick()
expect(wrapper.text()).to.contain('1 of 3')
await wrapper.find('.query-input input').setValue('Foo')
clock.tick(500)
expect(wrapper.emitted('update:nodesMatchingSearchQuery')[1][0]).to.eql([
'node-4'
])
expect(wrapper.emitted('update:focusedFoundNodeId')[6][0]).to.equal(null)
await wrapper.find('.query-input input').setValue('')
clock.tick(500)
expect(wrapper.emitted('update:nodesMatchingSearchQuery')[2][0]).to.eql(
null
)
expect(wrapper.emitted('update:focusedFoundNodeId')[7][0]).to.equal(null)
clock.restore()
})
it('matches case and uses reg exp', async () => {
const clock = sinon.useFakeTimers()
const wrapper = mount(GraphSearchPanel, {
props: {
graph: buildGraph(),
nodesMatchingSearchQuery: null,
focusedFoundNodeId: null,
'onUpdate:nodesMatchingSearchQuery': nodes => {
wrapper.setProps({ nodesMatchingSearchQuery: nodes })
},
'onUpdate:focusedFoundNodeId': nodeId => {
wrapper.setProps({ focusedFoundNodeId: nodeId })
}
}
})
await wrapper.find('.query-input input').setValue('A')
clock.tick(500)
expect(wrapper.emitted('update:nodesMatchingSearchQuery')[0][0]).to.eql([
'node-1',
'node-2',
'node-3'
])
const matchCaseBtn = wrapper.find('[data-test="match-case-btn"]')
const useRegExpBtn = wrapper.find('[data-test="use-reg-exp-btn"]')
await matchCaseBtn.trigger('click')
expect(wrapper.emitted('update:nodesMatchingSearchQuery')[1][0]).to.eql([
'node-1'
])
await wrapper.find('.query-input input').setValue('a.+a$')
clock.tick(500)
expect(wrapper.emitted('update:nodesMatchingSearchQuery')[2][0]).to.eql([])
await useRegExpBtn.trigger('click')
expect(wrapper.emitted('update:nodesMatchingSearchQuery')[3][0]).to.eql([
'node-3'
])
await matchCaseBtn.trigger('click')
expect(wrapper.emitted('update:nodesMatchingSearchQuery')[4][0]).to.eql([
'node-1',
'node-3'
])
clock.restore()
})
it('shows No items found and disables navigation arrows', async () => {
const clock = sinon.useFakeTimers()
const wrapper = mount(GraphSearchPanel, {
props: {
graph: buildGraph(),
nodesMatchingSearchQuery: null,
focusedFoundNodeId: null,
'onUpdate:nodesMatchingSearchQuery': nodes => {
wrapper.setProps({ nodesMatchingSearchQuery: nodes })
},
'onUpdate:focusedFoundNodeId': nodeId => {
wrapper.setProps({ focusedFoundNodeId: nodeId })
}
}
})
await wrapper.find('.query-input input').setValue('baz')
clock.tick(500)
expect(wrapper.emitted('update:nodesMatchingSearchQuery')[0][0]).to.eql([])
expect(wrapper.emitted('update:focusedFoundNodeId')[0][0]).to.equal(null)
await nextTick()
expect(wrapper.find('[data-test="no-results-text"]').isVisible()).to.be.true
await expect(
wrapper.find('[data-test="next-match-btn"]').attributes('disabled')
).to.not.equal(undefined)
await expect(
wrapper.find('[data-test="previous-match-btn"]').attributes('disabled')
).to.not.equal(undefined)
clock.restore()
})
it('handles invalid regular expressions by clearing matches', async () => {
const clock = sinon.useFakeTimers()
const wrapper = mount(GraphSearchPanel, {
props: {
graph: buildGraph(),
nodesMatchingSearchQuery: ['node-1'],
focusedFoundNodeId: 'node-1'
}
})
await wrapper.find('.query-input input').setValue('[')
clock.tick(500)
expect(wrapper.emitted('update:nodesMatchingSearchQuery')[0][0]).to.eql([])
const useRegExpBtn = wrapper.find('[data-test="use-reg-exp-btn"]')
await useRegExpBtn.trigger('click')
expect(
wrapper.emitted('update:nodesMatchingSearchQuery').at(-1)[0]
).to.equal(null)
expect(wrapper.emitted('update:focusedFoundNodeId').at(-1)[0]).to.equal(
null
)
expect(wrapper.text()).to.contain('Invalid regular expression')
clock.restore()
})
it('toggles the search panel visibility', async () => {
const wrapper = mount(GraphSearchPanel, {
props: {
graph: buildGraph(),
nodesMatchingSearchQuery: null,
focusedFoundNodeId: null
},
attachTo: document.body
})
const searchControls = wrapper.find('.graph-search-panel--search-controls')
expect(searchControls.isVisible()).to.be.false
const toggleButton = wrapper.find(
'[data-test="toggle-search-panel-visibility-btn"]'
)
await toggleButton.trigger('click')
expect(searchControls.isVisible()).to.be.true
await toggleButton.trigger('click')
expect(wrapper.vm.panelOpen).to.be.false
wrapper.unmount()
})
})