- BreadcrumbBar: refreshing spinner, fresh checkmark (fades 3s), stale warning banner - Accessible labels and role=status for all freshness states - formatters.js: add formatRelativeTime (shared, no duplication) - Version bump 0.1.5 → 0.1.6 in backend/main.py and frontend/package.json - AGENTS.md: update state, shared module map (formatRelativeTime) - README.md: unified connection-root browsing and capability explanations feature text - 17 BreadcrumbBar tests pass; 323 total; production build clean
198 lines
6.1 KiB
JavaScript
198 lines
6.1 KiB
JavaScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { mount } from '@vue/test-utils'
|
|
import { nextTick } from 'vue'
|
|
import BreadcrumbBar from '../BreadcrumbBar.vue'
|
|
|
|
const STUBS = { global: { stubs: { AppIcon: true } } }
|
|
|
|
function seg(id, label) { return { id, label } }
|
|
function staticSeg(label) { return { label } }
|
|
|
|
describe('BreadcrumbBar', () => {
|
|
it('renders rootLabel button when showRoot=true', () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { rootLabel: 'Home', segments: [] },
|
|
...STUBS,
|
|
})
|
|
expect(w.find('button').text()).toBe('Home')
|
|
})
|
|
|
|
it('does NOT render root button when showRoot=false', () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { showRoot: false, segments: [staticSeg('Users')] },
|
|
...STUBS,
|
|
})
|
|
expect(w.findAll('button').length).toBe(0)
|
|
})
|
|
|
|
it('clicking root button emits navigate(null)', async () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { segments: [] },
|
|
...STUBS,
|
|
})
|
|
await w.find('button').trigger('click')
|
|
expect(w.emitted('navigate')).toBeTruthy()
|
|
expect(w.emitted('navigate')[0]).toEqual([null])
|
|
})
|
|
|
|
it('last segment renders as a non-clickable <span>', () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { segments: [seg('a', 'A'), seg('b', 'B')] },
|
|
...STUBS,
|
|
})
|
|
const text = w.text()
|
|
expect(text).toContain('B')
|
|
const buttonWithB = w.findAll('button').filter(b => b.text() === 'B')
|
|
expect(buttonWithB.length).toBe(0)
|
|
})
|
|
|
|
it('clicking intermediate segment emits navigate(segment.id)', async () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { segments: [seg('r1', 'Root'), seg('f1', 'Test')] },
|
|
...STUBS,
|
|
})
|
|
const buttons = w.findAll('button')
|
|
const rootBtn = buttons.find(b => b.text() === 'Root')
|
|
await rootBtn.trigger('click')
|
|
expect(w.emitted('navigate')).toBeTruthy()
|
|
expect(w.emitted('navigate')[0]).toEqual(['r1'])
|
|
})
|
|
|
|
it('segments without id render as plain text even when intermediate', () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: {
|
|
showRoot: false,
|
|
segments: [staticSeg('Admin'), staticSeg('Users')],
|
|
},
|
|
...STUBS,
|
|
})
|
|
expect(w.findAll('button').length).toBe(0)
|
|
})
|
|
|
|
it('>4 segments collapse to first + ellipsis + last two', () => {
|
|
const segments = [
|
|
seg('a', 'Alpha'),
|
|
seg('b', 'Beta'),
|
|
seg('c', 'Gamma'),
|
|
seg('d', 'Delta'),
|
|
seg('e', 'Epsilon'),
|
|
]
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { segments },
|
|
...STUBS,
|
|
})
|
|
const text = w.text()
|
|
expect(text).toContain('Alpha')
|
|
expect(text).toContain('…')
|
|
expect(text).toContain('Delta')
|
|
expect(text).toContain('Epsilon')
|
|
expect(text).not.toContain('Beta')
|
|
expect(text).not.toContain('Gamma')
|
|
})
|
|
|
|
it('rootLabel defaults to "Home"', () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { segments: [] },
|
|
...STUBS,
|
|
})
|
|
expect(w.find('button').text()).toBe('Home')
|
|
})
|
|
|
|
it('custom rootLabel "Cloud" renders correctly', () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { rootLabel: 'Cloud', segments: [] },
|
|
...STUBS,
|
|
})
|
|
expect(w.find('button').text()).toBe('Cloud')
|
|
})
|
|
|
|
it('renders chevronRight separator between segments', () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { segments: [seg('a', 'A'), seg('b', 'B')] },
|
|
...STUBS,
|
|
})
|
|
const appIcons = w.findAll('app-icon-stub')
|
|
expect(appIcons.length).toBeGreaterThanOrEqual(1)
|
|
})
|
|
})
|
|
|
|
describe('BreadcrumbBar freshness indicator', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
})
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('shows refreshing spinner with accessible label when folderFreshness="refreshing"', () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { segments: [], folderFreshness: 'refreshing' },
|
|
...STUBS,
|
|
})
|
|
const indicator = w.find('[data-freshness="refreshing"]')
|
|
expect(indicator.exists()).toBe(true)
|
|
expect(indicator.attributes('aria-label')).toBe('Refreshing folder contents')
|
|
})
|
|
|
|
it('shows fresh checkmark with accessible label when folderFreshness="fresh"', async () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { segments: [], folderFreshness: 'fresh' },
|
|
...STUBS,
|
|
})
|
|
// Wait for watcher to update showFreshMark
|
|
await nextTick()
|
|
const fresh = w.find('[data-freshness="fresh"]')
|
|
expect(fresh.exists()).toBe(true)
|
|
expect(fresh.attributes('aria-label')).toBe('Folder contents are up to date')
|
|
})
|
|
|
|
it('fresh indicator fades after 3 seconds', async () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { segments: [], folderFreshness: 'fresh' },
|
|
...STUBS,
|
|
})
|
|
await nextTick()
|
|
expect(w.vm.showFreshMark).toBe(true)
|
|
// Advance timer by 3s
|
|
vi.advanceTimersByTime(3001)
|
|
await nextTick()
|
|
expect(w.vm.showFreshMark).toBe(false)
|
|
})
|
|
|
|
it('warning indicator persists when folderFreshness="stale"', async () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { segments: [], folderFreshness: 'stale', lastRefreshedAt: '2026-01-01T00:00:00Z' },
|
|
...STUBS,
|
|
})
|
|
const stale = w.find('[data-freshness="stale"]')
|
|
expect(stale.exists()).toBe(true)
|
|
})
|
|
|
|
it('stale warning banner is shown with last-update info', async () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { segments: [], folderFreshness: 'stale', lastRefreshedAt: '2026-01-01T00:00:00Z' },
|
|
...STUBS,
|
|
})
|
|
const banner = w.find('[data-test="stale-warning-banner"]')
|
|
expect(banner.exists()).toBe(true)
|
|
expect(banner.text()).toContain('Could not refresh folder contents')
|
|
})
|
|
|
|
it('no freshness indicator shown when folderFreshness is null', () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { segments: [], folderFreshness: null },
|
|
...STUBS,
|
|
})
|
|
expect(w.find('[data-test="freshness-indicator"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('freshness indicator role="status" for accessible announcement', async () => {
|
|
const w = mount(BreadcrumbBar, {
|
|
props: { segments: [], folderFreshness: 'refreshing' },
|
|
...STUBS,
|
|
})
|
|
const indicator = w.find('[data-freshness="refreshing"]')
|
|
expect(indicator.attributes('role')).toBe('status')
|
|
})
|
|
})
|