diff --git a/web/src/components/AppSidebar.test.ts b/web/src/components/AppSidebar.test.ts new file mode 100644 index 0000000..55fe600 --- /dev/null +++ b/web/src/components/AppSidebar.test.ts @@ -0,0 +1,124 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createWebHashHistory } from 'vue-router' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import AppSidebar from './AppSidebar.vue' + +// Minimal router so RouterLink renders without warnings +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { path: '/', component: { template: '
' } }, + { path: '/fleet', component: { template: '
' } }, + { path: '/data/label', component: { template: '
' } }, + { path: '/data/fetch', component: { template: '
' } }, + { path: '/data/corrections', component: { template: '
' } }, + { path: '/data/imitate', component: { template: '
' } }, + { path: '/eval/benchmark', component: { template: '
' } }, + { path: '/eval/compare', component: { template: '
' } }, + { path: '/train/jobs', component: { template: '
' } }, + { path: '/train/results', component: { template: '
' } }, + { path: '/settings', component: { template: '
' } }, + ], +}) + +function makeFetch(signals: Record = {}) { + return vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + labeled_since_last_eval: 0, + last_eval_timestamp: null, + last_eval_best_score: null, + active_jobs: [], + corrections_export_ready: 0, + signals, + }), + text: async () => '', + }) +} + +beforeEach(() => { + localStorage.clear() + vi.stubGlobal('fetch', makeFetch()) +}) + +describe('AppSidebar structure', () => { + it('renders section headers for Data, Eval, Train', async () => { + const w = mount(AppSidebar, { global: { plugins: [router] } }) + await flushPromises() + const text = w.text() + expect(text).toContain('Data') + expect(text).toContain('Eval') + expect(text).toContain('Train') + }) + + it('renders all sub-links', async () => { + const w = mount(AppSidebar, { global: { plugins: [router] } }) + await flushPromises() + const anchors = w.findAll('a') + const hrefs = anchors.map(a => a.attributes('href') ?? '') + expect(hrefs.some(h => h.includes('/data/label'))).toBe(true) + expect(hrefs.some(h => h.includes('/data/fetch'))).toBe(true) + expect(hrefs.some(h => h.includes('/data/corrections'))).toBe(true) + expect(hrefs.some(h => h.includes('/data/imitate'))).toBe(true) + expect(hrefs.some(h => h.includes('/eval/benchmark'))).toBe(true) + expect(hrefs.some(h => h.includes('/eval/compare'))).toBe(true) + expect(hrefs.some(h => h.includes('/train/jobs'))).toBe(true) + expect(hrefs.some(h => h.includes('/train/results'))).toBe(true) + expect(hrefs.some(h => h.includes('/fleet'))).toBe(true) + expect(hrefs.some(h => h.includes('/settings'))).toBe(true) + }) + + it('does NOT render the old /benchmark or /models links', async () => { + const w = mount(AppSidebar, { global: { plugins: [router] } }) + await flushPromises() + const anchors = w.findAll('a') + const hrefs = anchors.map(a => a.attributes('href') ?? '') + // Old paths must not appear as direct links (they're only redirects) + expect(hrefs.every(h => !h.endsWith('/#/benchmark'))).toBe(true) + expect(hrefs.every(h => !h.endsWith('/#/models'))).toBe(true) + expect(hrefs.every(h => !h.endsWith('/#/stats'))).toBe(true) + }) + + it('shows no signal badges when all signals are false', async () => { + vi.stubGlobal('fetch', makeFetch({ data_to_eval: false, eval_to_train: false, train_to_fleet: false })) + const w = mount(AppSidebar, { global: { plugins: [router] } }) + await flushPromises() + expect(w.findAll('.signal-badge').length).toBe(0) + }) + + it('shows signal badge on Data section when data_to_eval is true', async () => { + vi.stubGlobal('fetch', makeFetch({ data_to_eval: true, eval_to_train: false, train_to_fleet: false })) + const w = mount(AppSidebar, { global: { plugins: [router] } }) + await flushPromises() + const badges = w.findAll('.signal-badge') + expect(badges.length).toBe(1) + // It should be inside the Data section header + const dataHeader = w.find('[data-section="data"]') + expect(dataHeader.find('.signal-badge').exists()).toBe(true) + }) + + it('shows signal badge on Eval section when eval_to_train is true', async () => { + vi.stubGlobal('fetch', makeFetch({ data_to_eval: false, eval_to_train: true, train_to_fleet: false })) + const w = mount(AppSidebar, { global: { plugins: [router] } }) + await flushPromises() + const evalHeader = w.find('[data-section="eval"]') + expect(evalHeader.find('.signal-badge').exists()).toBe(true) + }) + + it('shows signal badge on Train section when train_to_fleet is true', async () => { + vi.stubGlobal('fetch', makeFetch({ data_to_eval: false, eval_to_train: false, train_to_fleet: true })) + const w = mount(AppSidebar, { global: { plugins: [router] } }) + await flushPromises() + const trainHeader = w.find('[data-section="train"]') + expect(trainHeader.find('.signal-badge').exists()).toBe(true) + }) + + it('stow toggle still works', async () => { + const w = mount(AppSidebar, { global: { plugins: [router] } }) + await flushPromises() + const nav = w.find('nav') + expect(nav.classes()).not.toContain('stowed') + await w.find('.stow-btn').trigger('click') + expect(nav.classes()).toContain('stowed') + }) +}) diff --git a/web/src/components/AppSidebar.vue b/web/src/components/AppSidebar.vue index 9fdb461..3967f0e 100644 --- a/web/src/components/AppSidebar.vue +++ b/web/src/components/AppSidebar.vue @@ -28,12 +28,59 @@
- + - +