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 @@
-
+
- -
+
+
-
+
+ 📊
+ Dashboard
+
+
+ -
+
+ ⚡
+ Fleet
+
+
+
+
+ -
+
+
+ -
@@ -41,10 +88,94 @@
{{ item.label }}
+
+
+ -
+
+
+ -
+
+ {{ item.icon }}
+ {{ item.label }}
+
+
+
+
+ -
+
+
+ -
+
+ {{ item.icon }}
+ {{ item.label }}
+
+
+
+
+
+ -
+
+ ⚙️
+ Settings
+
+
-
+