Files
ComfyUI_frontend/src/components/queue/job/JobGroupsList.test.ts
Benjamin Lu 14274fb8fa fix: share queue details hover state (#9924)
## Summary

Extract the shared delayed queue-details hover state into a composable
so the queue overlay and grouped queue rows use the same timing and
handoff behavior.

## Changes

- **What**: Add `useJobDetailsHover`, migrate `JobAssetsList` and
`JobGroupsList` to it, convert the touched helper functions to
declarations, and add a grouped-row regression test for the A -> B ->
leave stale-popover case.

## Review Focus

Verify that details hover timing stays consistent between the queue
overlay and grouped queue rows, especially around the A -> B hover
handoff and brief-hover exit path.

Stacked on #9549.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9924-fix-share-queue-details-hover-state-3236d73d36508108aca5dcfb99bf33c6)
by [Unito](https://www.unito.io)
2026-03-17 00:38:52 -07:00

128 lines
4.0 KiB
TypeScript

import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { TaskItemImpl } from '@/stores/queueStore'
const QueueJobItemStub = defineComponent({
name: 'QueueJobItemStub',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined },
state: { type: String, required: true },
title: { type: String, required: true },
rightText: { type: String, default: '' },
iconName: { type: String, default: undefined },
iconImageUrl: { type: String, default: undefined },
showClear: { type: Boolean, default: undefined },
showMenu: { type: Boolean, default: undefined },
progressTotalPercent: { type: Number, default: undefined },
progressCurrentPercent: { type: Number, default: undefined },
runningNodeName: { type: String, default: undefined },
activeDetailsId: { type: String, default: null }
},
template: '<div class="queue-job-item-stub"></div>'
})
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
const { taskRef, ...rest } = overrides
return {
id: 'job-id',
title: 'Example job',
meta: 'Meta text',
state: 'running',
iconName: 'icon',
iconImageUrl: 'https://example.com/icon.png',
showClear: true,
taskRef: (taskRef ?? {
workflow: { id: 'workflow-id' }
}) as TaskItemImpl,
progressTotalPercent: 60,
progressCurrentPercent: 30,
runningNodeName: 'Node A',
...rest
}
}
const mountComponent = (groups: JobGroup[]) =>
mount(JobGroupsList, {
props: { displayedJobGroups: groups },
global: {
stubs: {
QueueJobItem: QueueJobItemStub
}
}
})
describe('JobGroupsList hover behavior', () => {
afterEach(() => {
vi.useRealTimers()
})
it('delays showing and hiding details while hovering over job rows', async () => {
vi.useFakeTimers()
const job = createJobItem({ id: 'job-d' })
const wrapper = mountComponent([
{ key: 'today', label: 'Today', items: [job] }
])
const jobItem = wrapper.findComponent(QueueJobItemStub)
jobItem.vm.$emit('details-enter', job.id)
vi.advanceTimersByTime(199)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBeNull()
vi.advanceTimersByTime(1)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBe(job.id)
wrapper.findComponent(QueueJobItemStub).vm.$emit('details-leave', job.id)
vi.advanceTimersByTime(149)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBe(job.id)
vi.advanceTimersByTime(1)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBeNull()
})
it('clears the previous popover when hovering a new row briefly and leaving', async () => {
vi.useFakeTimers()
const firstJob = createJobItem({ id: 'job-1', title: 'First job' })
const secondJob = createJobItem({ id: 'job-2', title: 'Second job' })
const wrapper = mountComponent([
{ key: 'today', label: 'Today', items: [firstJob, secondJob] }
])
const jobItems = wrapper.findAllComponents(QueueJobItemStub)
jobItems[0].vm.$emit('details-enter', firstJob.id)
vi.advanceTimersByTime(200)
await nextTick()
expect(jobItems[0].props('activeDetailsId')).toBe(firstJob.id)
jobItems[0].vm.$emit('details-leave', firstJob.id)
jobItems[1].vm.$emit('details-enter', secondJob.id)
vi.advanceTimersByTime(100)
await nextTick()
jobItems[1].vm.$emit('details-leave', secondJob.id)
vi.advanceTimersByTime(50)
await nextTick()
expect(jobItems[0].props('activeDetailsId')).toBeNull()
vi.advanceTimersByTime(50)
await nextTick()
expect(jobItems[1].props('activeDetailsId')).toBeNull()
})
})