-
-
-
-
- {source.segment_position || index + 1}
+ data.sources.map((source, index) => {
+ const itemKey = source.document_id
+ ? `${source.document_id}-${source.segment_position ?? index}`
+ : source.index_node_hash ?? `${data.documentId ?? 'doc'}-${index}`
+
+ return (
+
+
+
+
+ {/* replaced svg component with tailwind icon class per lint rule */}
+
+
+ {source.segment_position || index + 1}
+
+ {
+ showHitInfo && (
+
+ {t('chat.citation.linkToDataset', { ns: 'common' })}
+
+
+ )
+ }
+
{source.content}
{
showHitInfo && (
-
- {t('chat.citation.linkToDataset', { ns: 'common' })}
-
-
+
+
}
+ />
+
}
+ />
+
}
+ />
+ {
+ !!source.score && (
+
+ )
+ }
+
)
}
- {source.content}
{
- showHitInfo && (
-
-
}
- />
-
}
- />
-
}
- />
- {
- !!source.score && (
-
- )
- }
-
+ index !== data.sources.length - 1 && (
+
)
}
-
- {
- index !== data.sources.length - 1 && (
-
- )
- }
-
- ))
+
+ )
+ })
}
diff --git a/web/app/components/base/chat/chat/citation/progress-tooltip.spec.tsx b/web/app/components/base/chat/chat/citation/progress-tooltip.spec.tsx
new file mode 100644
index 0000000000..a24c60c614
--- /dev/null
+++ b/web/app/components/base/chat/chat/citation/progress-tooltip.spec.tsx
@@ -0,0 +1,144 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import ProgressTooltip from './progress-tooltip'
+
+describe('ProgressTooltip', () => {
+ describe('Rendering', () => {
+ it('should render the trigger content', () => {
+ render(
)
+ expect(screen.getByTestId('progress-trigger-content')).toBeInTheDocument()
+ })
+
+ it('should render the data value in the trigger', () => {
+ render(
)
+ expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.75')
+ })
+
+ it('should render the progress bar fill element', () => {
+ render(
)
+ expect(screen.getByTestId('progress-bar-fill')).toBeInTheDocument()
+ })
+
+ it('should not render the tooltip popup before hovering', () => {
+ render(
)
+ expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Progress Bar Width', () => {
+ it('should set fill width to data * 100 percent', () => {
+ render(
)
+ expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '75%' })
+ })
+
+ it('should set fill width to 0% when data is 0', () => {
+ render(
)
+ expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '0%' })
+ })
+
+ it('should set fill width to 100% when data is 1', () => {
+ render(
)
+ expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '100%' })
+ })
+
+ it('should set fill width to 50% when data is 0.5', () => {
+ render(
)
+ expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '50%' })
+ })
+ })
+
+ describe('Tooltip Visibility', () => {
+ it('should show the tooltip popup on mouse enter', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ await user.hover(screen.getByTestId('progress-trigger-content'))
+
+ expect(screen.getByTestId('progress-tooltip-popup')).toBeInTheDocument()
+ })
+
+ it('should hide the tooltip popup on mouse leave', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ await user.hover(screen.getByTestId('progress-trigger-content'))
+ await user.unhover(screen.getByTestId('progress-trigger-content'))
+
+ expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
+ })
+
+ it('should show the hitScore i18n key in the tooltip', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ await user.hover(screen.getByTestId('progress-trigger-content'))
+
+ expect(screen.getByTestId('progress-tooltip-popup')).toHaveTextContent(/hitScore/i)
+ })
+
+ it('should show the data value inside the tooltip popup', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ await user.hover(screen.getByTestId('progress-trigger-content'))
+
+ expect(screen.getByTestId('progress-tooltip-popup')).toHaveTextContent('0.8')
+ })
+ })
+
+ describe('Props', () => {
+ it('should render correctly with a small fractional value', () => {
+ render(
)
+ expect(screen.getByTestId('progress-bar-fill').getAttribute('style')).toMatch(/width:\s*12/)
+ expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.12')
+ })
+
+ it('should render correctly with a value close to 1', () => {
+ render(
)
+ expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '99%' })
+ })
+
+ it('should update displayed data when prop changes', () => {
+ const { rerender } = render(
)
+ expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.3')
+
+ rerender(
)
+ expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.9')
+ expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '90%' })
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should render without crashing when data is exactly 0', () => {
+ expect(() => render(
)).not.toThrow()
+ })
+
+ it('should render without crashing when data is exactly 1', () => {
+ expect(() => render(
)).not.toThrow()
+ })
+
+ it('should re-show tooltip after hover β unhover β hover cycle', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ await user.hover(screen.getByTestId('progress-trigger-content'))
+ await user.unhover(screen.getByTestId('progress-trigger-content'))
+ await user.hover(screen.getByTestId('progress-trigger-content'))
+
+ expect(screen.getByTestId('progress-tooltip-popup')).toBeInTheDocument()
+ })
+
+ it('should keep tooltip closed without any interaction', () => {
+ render(
)
+ expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
+ })
+
+ it('should not call any external handlers by default', () => {
+ const consoleError = vi.spyOn(console, 'error')
+ render(
)
+ expect(consoleError).not.toHaveBeenCalled()
+ consoleError.mockRestore()
+ })
+ })
+})
diff --git a/web/app/components/base/chat/chat/citation/progress-tooltip.tsx b/web/app/components/base/chat/chat/citation/progress-tooltip.tsx
index e1ee3871f8..7915a09d20 100644
--- a/web/app/components/base/chat/chat/citation/progress-tooltip.tsx
+++ b/web/app/components/base/chat/chat/citation/progress-tooltip.tsx
@@ -27,15 +27,20 @@ const ProgressTooltip: FC
= ({
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
-
+
-
+
{t('chat.citation.hitScore', { ns: 'common' })}
{' '}
{data}
diff --git a/web/app/components/base/chat/chat/citation/tooltip.spec.tsx b/web/app/components/base/chat/chat/citation/tooltip.spec.tsx
new file mode 100644
index 0000000000..d5c1b57d76
--- /dev/null
+++ b/web/app/components/base/chat/chat/citation/tooltip.spec.tsx
@@ -0,0 +1,155 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it } from 'vitest'
+import Tooltip from './tooltip'
+
+const renderTooltip = (data: number | string = 42, text = 'Characters', icon = icon) =>
+ render()
+
+describe('Tooltip', () => {
+ describe('Rendering', () => {
+ it('should render the trigger content wrapper', () => {
+ renderTooltip()
+ expect(screen.getByTestId('tooltip-trigger-content')).toBeInTheDocument()
+ })
+
+ it('should render the icon inside the trigger', () => {
+ renderTooltip(42, 'Characters', icon)
+ expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
+ })
+
+ it('should render a numeric data value in the trigger', () => {
+ renderTooltip(123)
+ expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('123')
+ })
+
+ it('should render a string data value in the trigger', () => {
+ renderTooltip('abc123')
+ expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('abc123')
+ })
+
+ it('should not render the tooltip popup before hovering', () => {
+ renderTooltip()
+ expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Props', () => {
+ it('should render the provided text label when tooltip is open', async () => {
+ const user = userEvent.setup()
+ renderTooltip(10, 'Word Count')
+
+ await user.hover(screen.getByTestId('tooltip-trigger-content'))
+
+ expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Word Count')
+ })
+
+ it('should render the data value inside the tooltip popup', async () => {
+ const user = userEvent.setup()
+ renderTooltip(99, 'Hit Count')
+
+ await user.hover(screen.getByTestId('tooltip-trigger-content'))
+
+ expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('99')
+ })
+
+ it('should render a string data value inside the tooltip popup', async () => {
+ const user = userEvent.setup()
+ renderTooltip('abc1234', 'Vector Hash')
+
+ await user.hover(screen.getByTestId('tooltip-trigger-content'))
+
+ expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('abc1234')
+ })
+
+ it('should render both text and data together inside the tooltip popup', async () => {
+ const user = userEvent.setup()
+ renderTooltip(55, 'Characters')
+
+ await user.hover(screen.getByTestId('tooltip-trigger-content'))
+
+ const popup = screen.getByTestId('tooltip-popup')
+ expect(popup).toHaveTextContent('Characters')
+ expect(popup).toHaveTextContent('55')
+ })
+
+ it('should render any arbitrary ReactNode as icon', () => {
+ render(β
} />)
+ expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
+ })
+
+ it('should update displayed data when prop changes', () => {
+ const { rerender } = render(
} />)
+ expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('10')
+
+ rerender(
} />)
+ expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('20')
+ })
+
+ it('should update displayed text in popup when prop changes and tooltip is open', async () => {
+ const user = userEvent.setup()
+ const { rerender } = render(
} />)
+ await user.hover(screen.getByTestId('tooltip-trigger-content'))
+ expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Original')
+
+ rerender(
} />)
+ expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Updated')
+ })
+ })
+
+ describe('Tooltip Visibility', () => {
+ it('should show the tooltip popup on mouse enter', async () => {
+ const user = userEvent.setup()
+ renderTooltip()
+
+ await user.hover(screen.getByTestId('tooltip-trigger-content'))
+
+ expect(screen.getByTestId('tooltip-popup')).toBeInTheDocument()
+ })
+
+ it('should hide the tooltip popup on mouse leave', async () => {
+ const user = userEvent.setup()
+ renderTooltip()
+
+ await user.hover(screen.getByTestId('tooltip-trigger-content'))
+ await user.unhover(screen.getByTestId('tooltip-trigger-content'))
+
+ expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
+ })
+
+ it('should re-show tooltip after hover β unhover β hover cycle', async () => {
+ const user = userEvent.setup()
+ renderTooltip()
+
+ await user.hover(screen.getByTestId('tooltip-trigger-content'))
+ await user.unhover(screen.getByTestId('tooltip-trigger-content'))
+ await user.hover(screen.getByTestId('tooltip-trigger-content'))
+
+ expect(screen.getByTestId('tooltip-popup')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should render without crashing when data is 0', () => {
+ expect(() => render(
} />)).not.toThrow()
+ })
+
+ it('should render without crashing when data is an empty string', () => {
+ expect(() => render(
} />)).not.toThrow()
+ })
+
+ it('should render without crashing when text is an empty string', () => {
+ expect(() => render(
} />)).not.toThrow()
+ })
+
+ it('should keep tooltip closed without any interaction', () => {
+ renderTooltip(0.5)
+ expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
+ })
+
+ it('should render data value 0 in the trigger', () => {
+ render(
} />)
+ expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('0')
+ })
+ })
+})
diff --git a/web/app/components/base/chat/chat/citation/tooltip.tsx b/web/app/components/base/chat/chat/citation/tooltip.tsx
index a4ac64c82f..d23234a660 100644
--- a/web/app/components/base/chat/chat/citation/tooltip.tsx
+++ b/web/app/components/base/chat/chat/citation/tooltip.tsx
@@ -30,13 +30,13 @@ const Tooltip: FC
= ({
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
-
+
{icon}
{data}
-
+
{text}
{' '}
{data}
diff --git a/web/app/components/base/chat/chat/content-switch.spec.tsx b/web/app/components/base/chat/chat/content-switch.spec.tsx
new file mode 100644
index 0000000000..5f87ceb6f2
--- /dev/null
+++ b/web/app/components/base/chat/chat/content-switch.spec.tsx
@@ -0,0 +1,79 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import ContentSwitch from './content-switch'
+
+describe('ContentSwitch', () => {
+ const defaultProps = {
+ count: 3,
+ currentIndex: 1,
+ prevDisabled: false,
+ nextDisabled: false,
+ switchSibling: vi.fn(),
+ }
+
+ it('renders nothing when count is 1 or less', () => {
+ const { container } = render(
)
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('renders nothing when currentIndex is undefined', () => {
+ const { container } = render(
)
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('renders correctly with current page and total count', () => {
+ render(
)
+ expect(screen.getByText(/1[^\n\r/\u2028\u2029]*\/.*5/)).toBeInTheDocument()
+ })
+
+ it('calls switchSibling with "prev" when left button is clicked', async () => {
+ const user = userEvent.setup()
+ const switchSibling = vi.fn()
+ render(
)
+
+ const prevButton = screen.getByRole('button', { name: /previous/i })
+ await user.click(prevButton)
+
+ expect(switchSibling).toHaveBeenCalledWith('prev')
+ })
+
+ it('calls switchSibling with "next" when right button is clicked', async () => {
+ const user = userEvent.setup()
+ const switchSibling = vi.fn()
+ render(
)
+
+ const nextButton = screen.getByRole('button', { name: /next/i })
+ await user.click(nextButton)
+
+ expect(switchSibling).toHaveBeenCalledWith('next')
+ })
+
+ it('applies disabled styles and prevents clicks when prevDisabled is true', async () => {
+ const user = userEvent.setup()
+ const switchSibling = vi.fn()
+ render(
)
+
+ const prevButton = screen.getByRole('button', { name: /previous/i })
+
+ expect(prevButton).toHaveClass('opacity-30')
+ expect(prevButton).toBeDisabled()
+
+ await user.click(prevButton)
+ expect(switchSibling).not.toHaveBeenCalled()
+ })
+
+ it('applies disabled styles and prevents clicks when nextDisabled is true', async () => {
+ const user = userEvent.setup()
+ const switchSibling = vi.fn()
+ render(
)
+
+ const nextButton = screen.getByRole('button', { name: /next/i })
+
+ expect(nextButton).toHaveClass('opacity-30')
+ expect(nextButton).toBeDisabled()
+
+ await user.click(nextButton)
+ expect(switchSibling).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/base/chat/chat/content-switch.tsx b/web/app/components/base/chat/chat/content-switch.tsx
index e17d3de019..ee735c61fd 100644
--- a/web/app/components/base/chat/chat/content-switch.tsx
+++ b/web/app/components/base/chat/chat/content-switch.tsx
@@ -18,6 +18,7 @@ export default function ContentSwitch({