|
| 1 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
| 2 | +import { mount } from '@vue/test-utils' |
| 3 | +import { nextTick, reactive } from 'vue' |
| 4 | +import PaperHomeView from '../../../views/paper/PaperHomeView.vue' |
| 5 | +import type { HomeSummary } from '../../../types/workspace' |
| 6 | + |
| 7 | +/** |
| 8 | + * PaperHomeView — vitest coverage for greeting, queue rendering, empty |
| 9 | + * state, capture dispatch guard, and ember-accent rules. |
| 10 | + * |
| 11 | + * The view reads from sessionStore, workspaceStore, captureStore, and |
| 12 | + * vue-router; we mock all four with lightweight reactive stand-ins. The |
| 13 | + * greeting is time-of-day sensitive, so each test pins the system clock |
| 14 | + * with vi.useFakeTimers / setSystemTime. |
| 15 | + */ |
| 16 | + |
| 17 | +const routerMocks = vi.hoisted(() => ({ |
| 18 | + push: vi.fn(), |
| 19 | +})) |
| 20 | + |
| 21 | +const mockSessionStore = reactive({ |
| 22 | + username: 'daniel' as string | null, |
| 23 | +}) |
| 24 | + |
| 25 | +const mockWorkspaceStore = reactive({ |
| 26 | + homeSummary: null as HomeSummary | null, |
| 27 | + homeLoading: false, |
| 28 | + homeError: null as string | null, |
| 29 | + hasHomeSummary: false, |
| 30 | + fetchHomeSummary: vi.fn<() => Promise<void>>(), |
| 31 | +}) |
| 32 | + |
| 33 | +const mockCaptureStore = { |
| 34 | + createItem: vi.fn(), |
| 35 | +} |
| 36 | + |
| 37 | +vi.mock('../../../store/sessionStore', () => ({ |
| 38 | + useSessionStore: () => mockSessionStore, |
| 39 | +})) |
| 40 | + |
| 41 | +vi.mock('../../../store/workspaceStore', () => ({ |
| 42 | + useWorkspaceStore: () => mockWorkspaceStore, |
| 43 | +})) |
| 44 | + |
| 45 | +vi.mock('../../../store/captureStore', () => ({ |
| 46 | + useCaptureStore: () => mockCaptureStore, |
| 47 | +})) |
| 48 | + |
| 49 | +vi.mock('vue-router', () => ({ |
| 50 | + useRouter: () => ({ push: routerMocks.push }), |
| 51 | +})) |
| 52 | + |
| 53 | +function buildSummary(overrides?: Partial<HomeSummary>): HomeSummary { |
| 54 | + return { |
| 55 | + workspaceMode: 'guided', |
| 56 | + isFirstRun: false, |
| 57 | + onboarding: { |
| 58 | + visibility: 'active', |
| 59 | + isComplete: false, |
| 60 | + currentStepId: null, |
| 61 | + dismissedAt: null, |
| 62 | + completedAt: null, |
| 63 | + steps: [], |
| 64 | + }, |
| 65 | + workload: { |
| 66 | + capturesNeedingTriage: 0, |
| 67 | + capturesInProgress: 0, |
| 68 | + capturesReadyForFollowUp: 0, |
| 69 | + proposalsPendingReview: 0, |
| 70 | + }, |
| 71 | + boards: { |
| 72 | + totalBoards: 0, |
| 73 | + recentBoardsCount: 0, |
| 74 | + recentBoards: [], |
| 75 | + }, |
| 76 | + recommendedActions: [], |
| 77 | + ...overrides, |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +describe('PaperHomeView', () => { |
| 82 | + beforeEach(() => { |
| 83 | + vi.clearAllMocks() |
| 84 | + mockSessionStore.username = 'daniel' |
| 85 | + mockWorkspaceStore.homeSummary = buildSummary() |
| 86 | + mockWorkspaceStore.homeLoading = false |
| 87 | + mockWorkspaceStore.homeError = null |
| 88 | + mockWorkspaceStore.hasHomeSummary = true |
| 89 | + mockWorkspaceStore.fetchHomeSummary.mockResolvedValue(undefined) |
| 90 | + mockCaptureStore.createItem.mockReset() |
| 91 | + mockCaptureStore.createItem.mockResolvedValue({ id: 'capture-1' }) |
| 92 | + }) |
| 93 | + |
| 94 | + afterEach(() => { |
| 95 | + vi.useRealTimers() |
| 96 | + }) |
| 97 | + |
| 98 | + describe('greeting period', () => { |
| 99 | + it.each([ |
| 100 | + ['08:00 local', new Date(2026, 3, 25, 8, 0, 0), 'Good morning', 'morning'], |
| 101 | + ['12:30 local', new Date(2026, 3, 25, 12, 30, 0), 'Good afternoon', 'afternoon'], |
| 102 | + ['19:00 local', new Date(2026, 3, 25, 19, 0, 0), 'Good evening', 'evening'], |
| 103 | + ])('picks %s', (_label, fixedNow, opener, period) => { |
| 104 | + vi.useFakeTimers() |
| 105 | + vi.setSystemTime(fixedNow as Date) |
| 106 | + |
| 107 | + const wrapper = mount(PaperHomeView) |
| 108 | + const greeting = wrapper.get('[data-testid="paper-home-greeting"]') |
| 109 | + const eyebrow = wrapper.get('[data-testid="paper-home-period"]') |
| 110 | + |
| 111 | + expect(greeting.text()).toContain(opener as string) |
| 112 | + expect(greeting.text()).toContain('Daniel') |
| 113 | + expect(eyebrow.text()).toContain(period as string) |
| 114 | + }) |
| 115 | + |
| 116 | + it('falls back to "Hello" when no first name is available', () => { |
| 117 | + vi.useFakeTimers() |
| 118 | + vi.setSystemTime(new Date(2026, 3, 25, 9, 0, 0)) |
| 119 | + mockSessionStore.username = null |
| 120 | + |
| 121 | + const wrapper = mount(PaperHomeView) |
| 122 | + const greeting = wrapper.get('[data-testid="paper-home-greeting"]') |
| 123 | + |
| 124 | + expect(greeting.text()).toBe('Hello.') |
| 125 | + expect(greeting.text()).not.toContain('Good morning') |
| 126 | + }) |
| 127 | + |
| 128 | + it('supports Unicode first-name tokens', () => { |
| 129 | + vi.useFakeTimers() |
| 130 | + vi.setSystemTime(new Date(2026, 3, 25, 9, 0, 0)) |
| 131 | + mockSessionStore.username = 'élodie.smith@example.test' |
| 132 | + |
| 133 | + const wrapper = mount(PaperHomeView) |
| 134 | + |
| 135 | + expect(wrapper.get('[data-testid="paper-home-greeting"]').text()).toContain('Élodie') |
| 136 | + }) |
| 137 | + |
| 138 | + it('refreshes the greeting period while the view stays active', async () => { |
| 139 | + vi.useFakeTimers() |
| 140 | + vi.setSystemTime(new Date(2026, 3, 25, 11, 59, 30)) |
| 141 | + |
| 142 | + const wrapper = mount(PaperHomeView) |
| 143 | + expect(wrapper.get('[data-testid="paper-home-period"]').text()).toContain('morning') |
| 144 | + |
| 145 | + await vi.advanceTimersByTimeAsync(60_000) |
| 146 | + await nextTick() |
| 147 | + |
| 148 | + expect(wrapper.get('[data-testid="paper-home-period"]').text()).toContain('afternoon') |
| 149 | + }) |
| 150 | + }) |
| 151 | + |
| 152 | + describe('queue cards', () => { |
| 153 | + it('renders loading instead of an empty queue while summary is missing', () => { |
| 154 | + mockWorkspaceStore.homeSummary = null |
| 155 | + mockWorkspaceStore.hasHomeSummary = false |
| 156 | + mockWorkspaceStore.homeLoading = true |
| 157 | + |
| 158 | + const wrapper = mount(PaperHomeView) |
| 159 | + |
| 160 | + expect(wrapper.find('[data-testid="paper-home-loading"]').exists()).toBe(true) |
| 161 | + expect(wrapper.find('[data-testid="paper-home-empty"]').exists()).toBe(false) |
| 162 | + expect(wrapper.find('.paper-home__queue').exists()).toBe(false) |
| 163 | + }) |
| 164 | + |
| 165 | + it('renders the store error instead of an empty queue when summary loading fails', () => { |
| 166 | + mockWorkspaceStore.homeSummary = null |
| 167 | + mockWorkspaceStore.hasHomeSummary = false |
| 168 | + mockWorkspaceStore.homeError = 'Failed to load workspace summary' |
| 169 | + |
| 170 | + const wrapper = mount(PaperHomeView) |
| 171 | + |
| 172 | + expect(wrapper.find('[data-testid="paper-home-error"]').text()).toContain('Failed to load workspace summary') |
| 173 | + expect(wrapper.find('[data-testid="paper-home-empty"]').exists()).toBe(false) |
| 174 | + expect(wrapper.find('.paper-home__queue').exists()).toBe(false) |
| 175 | + }) |
| 176 | + |
| 177 | + it('renders refresh errors even when a cached summary exists', () => { |
| 178 | + mockWorkspaceStore.homeSummary = buildSummary({ |
| 179 | + workload: { |
| 180 | + capturesNeedingTriage: 0, |
| 181 | + capturesInProgress: 0, |
| 182 | + capturesReadyForFollowUp: 0, |
| 183 | + proposalsPendingReview: 0, |
| 184 | + }, |
| 185 | + }) |
| 186 | + mockWorkspaceStore.hasHomeSummary = true |
| 187 | + mockWorkspaceStore.homeError = 'Could not refresh workspace summary' |
| 188 | + |
| 189 | + const wrapper = mount(PaperHomeView) |
| 190 | + |
| 191 | + expect(wrapper.find('[data-testid="paper-home-error"]').text()).toContain('Could not refresh workspace summary') |
| 192 | + expect(wrapper.find('[data-testid="paper-home-empty"]').exists()).toBe(false) |
| 193 | + expect(wrapper.get('[data-testid="paper-home-lede"]').text()).toContain('Could not refresh workspace summary') |
| 194 | + }) |
| 195 | + |
| 196 | + it('renders the empty state when nothing is queued', () => { |
| 197 | + mockWorkspaceStore.homeSummary = buildSummary() |
| 198 | + const wrapper = mount(PaperHomeView) |
| 199 | + |
| 200 | + expect(wrapper.find('[data-testid="paper-home-empty"]').exists()).toBe(true) |
| 201 | + expect(wrapper.find('[data-testid="paper-home-empty"]').text()).toContain('Nothing waiting') |
| 202 | + expect(wrapper.find('[data-testid="paper-home-card-proposal"]').exists()).toBe(false) |
| 203 | + expect(wrapper.find('[data-testid="paper-home-card-carryover"]').exists()).toBe(false) |
| 204 | + }) |
| 205 | + |
| 206 | + it('only marks proposal entries with the ember halo, not carry-overs', () => { |
| 207 | + mockWorkspaceStore.homeSummary = buildSummary({ |
| 208 | + workload: { |
| 209 | + capturesNeedingTriage: 2, |
| 210 | + capturesInProgress: 0, |
| 211 | + capturesReadyForFollowUp: 0, |
| 212 | + proposalsPendingReview: 1, |
| 213 | + }, |
| 214 | + recommendedActions: [ |
| 215 | + { |
| 216 | + actionId: 'review-proposals', |
| 217 | + title: 'Review pending proposals', |
| 218 | + description: 'One awaits decision.', |
| 219 | + targetSurface: 'review', |
| 220 | + attentionCount: 1, |
| 221 | + }, |
| 222 | + ], |
| 223 | + }) |
| 224 | + |
| 225 | + const wrapper = mount(PaperHomeView) |
| 226 | + const proposalCards = wrapper.findAll('[data-testid="paper-home-card-proposal"]') |
| 227 | + const carryoverCards = wrapper.findAll('[data-testid="paper-home-card-carryover"]') |
| 228 | + |
| 229 | + expect(proposalCards).toHaveLength(1) |
| 230 | + expect(carryoverCards.length).toBeGreaterThan(0) |
| 231 | + |
| 232 | + // Proposals carry the ember halo; carry-overs do not. |
| 233 | + expect(proposalCards[0].classes()).toContain('halo-ember') |
| 234 | + carryoverCards.forEach((card) => { |
| 235 | + expect(card.classes()).not.toContain('halo-ember') |
| 236 | + }) |
| 237 | + }) |
| 238 | + }) |
| 239 | + |
| 240 | + describe('quick capture', () => { |
| 241 | + it('cleans up the global capture shortcut listener on unmount', () => { |
| 242 | + const addSpy = vi.spyOn(window, 'addEventListener') |
| 243 | + const removeSpy = vi.spyOn(window, 'removeEventListener') |
| 244 | + |
| 245 | + const wrapper = mount(PaperHomeView) |
| 246 | + wrapper.unmount() |
| 247 | + |
| 248 | + expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function)) |
| 249 | + expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function)) |
| 250 | + }) |
| 251 | + |
| 252 | + it('does not dispatch on empty Enter', async () => { |
| 253 | + const wrapper = mount(PaperHomeView) |
| 254 | + const input = wrapper.get('[data-testid="paper-home-capture-input"]') |
| 255 | + |
| 256 | + await input.setValue(' ') |
| 257 | + await wrapper.get('form').trigger('submit.prevent') |
| 258 | + |
| 259 | + expect(mockCaptureStore.createItem).not.toHaveBeenCalled() |
| 260 | + }) |
| 261 | + |
| 262 | + it('dispatches a typed capture and refreshes the home summary on submit', async () => { |
| 263 | + const wrapper = mount(PaperHomeView) |
| 264 | + const input = wrapper.get('[data-testid="paper-home-capture-input"]') |
| 265 | + |
| 266 | + await input.setValue('Refactor the queue store') |
| 267 | + await wrapper.get('form').trigger('submit.prevent') |
| 268 | + await Promise.resolve() |
| 269 | + |
| 270 | + expect(mockCaptureStore.createItem).toHaveBeenCalledTimes(1) |
| 271 | + expect(mockCaptureStore.createItem).toHaveBeenCalledWith({ |
| 272 | + boardId: null, |
| 273 | + text: 'Refactor the queue store', |
| 274 | + source: 'Typed', |
| 275 | + }) |
| 276 | + expect(mockWorkspaceStore.fetchHomeSummary).toHaveBeenCalledTimes(1) |
| 277 | + }) |
| 278 | + }) |
| 279 | +}) |
0 commit comments