opsdash-app/opsdash/test/OnboardingWizard.test.ts

454 lines
15 KiB
TypeScript

import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, it, expect, vi } from 'vitest'
const { postJsonMock, fetchDeckBoardsMetaMock } = vi.hoisted(() => ({
postJsonMock: vi.fn(),
fetchDeckBoardsMetaMock: vi.fn(),
}))
vi.mock('../composables/useOcHttp', () => ({
useOcHttp: () => ({
route: (name: string) => `/${name}`,
postJson: postJsonMock,
}),
}))
vi.mock('../src/services/deck', () => ({
fetchDeckBoardsMeta: fetchDeckBoardsMetaMock,
}))
import OnboardingWizard from '../src/components/onboarding/OnboardingWizard.vue'
function mountWizard(overrides: Record<string, any> = {}) {
return mount(OnboardingWizard, {
props: {
visible: true,
calendars: [{ id: 'cal-1', displayname: 'Primary', color: '#ff0000' }],
initialSelection: ['cal-1'],
initialStrategy: 'total_only',
onboardingVersion: 1,
saving: false,
closable: true,
initialThemePreference: 'auto',
systemTheme: 'light',
initialAllDayHours: 8,
initialTotalHours: 40,
hasExistingConfig: true,
snapshotSaving: false,
snapshotNotice: null,
...overrides,
},
})
}
describe('OnboardingWizard', () => {
beforeEach(() => {
postJsonMock.mockReset()
fetchDeckBoardsMetaMock.mockReset()
postJsonMock.mockResolvedValue({
charts: { perDaySeries: { series: [] } },
byCal: [],
})
fetchDeckBoardsMetaMock.mockResolvedValue([])
})
it('locks body scroll while visible', async () => {
const wrapper = mountWizard()
expect(document.body.classList.contains('opsdash-onboarding-lock')).toBe(true)
expect(document.body.dataset.opsdashOnboarding).toBe('1')
await wrapper.setProps({ visible: false })
expect(document.body.classList.contains('opsdash-onboarding-lock')).toBe(false)
expect(document.body.dataset.opsdashOnboarding).toBeUndefined()
wrapper.unmount()
expect(document.body.classList.contains('opsdash-onboarding-lock')).toBe(false)
})
it('disables the snapshot button while saving', async () => {
const wrapper = mountWizard({ snapshotSaving: true })
const button = wrapper.find('.config-warning button')
expect(button.attributes('disabled')).toBeDefined()
})
it('renders snapshot notices when provided', () => {
const wrapper = mountWizard({
snapshotNotice: { type: 'success', message: 'Profile saved' },
})
const notice = wrapper.find('.snapshot-notice')
expect(notice.exists()).toBe(true)
expect(notice.text()).toContain('Profile saved')
})
it('honors startStep and allows jumping via step pills', async () => {
const wrapper = mountWizard({
startStep: 'goals',
initialStrategy: 'full_granular',
})
const arrows = wrapper.findAll('.step-arrow')
const labels = arrows.map((p) => p.text())
expect(labels.some((label) => label.includes('Goals'))).toBe(true)
const goalsArrow = arrows.find((p) => p.text().includes('Goals'))
expect(goalsArrow?.classes()).toContain('current')
const calendarsArrow = arrows.find((p) => p.text().includes('Calendars'))
await calendarsArrow?.trigger('click')
await flushPromises()
expect(wrapper.find('.step-arrow.current').text()).toContain('Calendars')
})
it('shows global trend lookback choices in preferences after opening the editor', async () => {
const wrapper = mountWizard({
startStep: 'preferences',
initialTargetsConfig: { balanceTrendLookback: 5 },
})
const lookbackRow = wrapper.findAll('.field-row').find((row) => row.text().includes('Trend lookback'))
const openButton = lookbackRow?.findAll('button').find((button) => button.text().includes('Choose'))
await openButton?.trigger('click')
const editor = wrapper.find('.editor-card')
expect(editor.exists()).toBe(true)
expect(editor.text()).toContain('Open trend lookback selection')
expect(editor.text()).toContain('5 weeks')
})
it('uses a dedicated deck boards step', () => {
const wrapper = mountWizard({
startStep: 'deck',
})
expect(wrapper.find('.step-arrow.current').text()).toContain('Deck')
expect(wrapper.text()).toContain('Select Deck boards to include')
})
it('continues to calendars on strategy card double click', async () => {
const wrapper = mountWizard({
startStep: 'strategy',
initialStrategy: 'total_only',
})
const card = wrapper.findAll('.strategy-route-card').find((node) => node.text().includes('Calendar Goals'))
await card?.trigger('dblclick')
await flushPromises()
expect(wrapper.find('.step-arrow.current').text()).toContain('Calendars')
})
it('auto-saves before continuing to the next step', async () => {
const persistStep = vi.fn().mockResolvedValue(undefined)
const wrapper = mountWizard({
startStep: 'strategy',
initialStrategy: 'total_only',
persistStep,
})
const continueButton = wrapper.findAll('button').find((button) => button.text().includes('Continue'))
await continueButton?.trigger('click')
await flushPromises()
expect(persistStep).toHaveBeenCalledTimes(1)
expect(wrapper.find('.step-arrow.current').text()).toContain('Calendars')
})
it('loads goal suggestions from previous weeks instead of future weeks', async () => {
const wrapper = mountWizard({
startStep: 'goals',
initialStrategy: 'full_granular',
calendars: [
{ id: 'cal-1', displayname: 'Primary', color: '#ff0000' },
{ id: 'cal-2', displayname: 'Secondary', color: '#00ff00' },
],
initialSelection: ['cal-1', 'cal-2'],
})
await flushPromises()
const loadCalls = postJsonMock.mock.calls
.map((call) => call[1])
.filter((payload) => payload?.range === 'week' && Array.isArray(payload?.include) && payload.include.includes('data'))
const offsets = loadCalls.map((payload) => payload.offset)
expect(new Set(offsets)).toEqual(new Set([-1, -2, -3, -4, -5, -6]))
expect(offsets.every((offset) => offset < 0)).toBe(true)
wrapper.unmount()
})
it('shows suggestions in single goal mode from recent history', async () => {
postJsonMock.mockResolvedValue({
charts: {
perDaySeries: {
series: [
{ id: 'cal-1', data: [6] },
],
},
},
byCal: [{ id: 'cal-1', total_hours: 6 }],
})
const wrapper = mountWizard({
startStep: 'goals',
initialStrategy: 'total_only',
})
await flushPromises()
expect(wrapper.text()).toContain('Suggested from recent activity:')
expect(wrapper.text()).toContain('6.0 h / week')
})
it('applies the single goal suggestion when clicked', async () => {
postJsonMock.mockResolvedValue({
charts: {
perDaySeries: {
series: [
{ id: 'cal-1', data: [6] },
],
},
},
byCal: [{ id: 'cal-1', total_hours: 6 }],
})
const wrapper = mountWizard({
startStep: 'goals',
initialStrategy: 'total_only',
})
await flushPromises()
const suggestion = wrapper.findAll('button').find((button) => button.text().includes('6.0 h / week'))
expect(suggestion).toBeTruthy()
await suggestion?.trigger('click')
await flushPromises()
const weeklyInput = wrapper.find('.goal-single__editor input[type="number"]')
expect((weeklyInput.element as HTMLInputElement).value).toBe('6')
})
it('shows category suggestions when existing assignments map calendars to a category', async () => {
postJsonMock.mockResolvedValue({
charts: {
perDaySeries: {
series: [
{ id: 'cal-1', data: [6] },
{ id: 'cal-2', data: [2] },
],
},
},
byCal: [
{ id: 'cal-1', total_hours: 6 },
{ id: 'cal-2', total_hours: 2 },
],
})
const wrapper = mountWizard({
startStep: 'goals',
initialStrategy: 'full_granular',
calendars: [
{ id: 'cal-1', displayname: 'Primary', color: '#ff0000' },
{ id: 'cal-2', displayname: 'Secondary', color: '#00ff00' },
],
initialSelection: ['cal-1', 'cal-2'],
initialCategories: [
{
id: 'work',
label: 'Work',
targetHours: 12,
includeWeekend: false,
paceMode: 'days_only',
color: '#2563EB',
},
],
initialAssignments: {
'cal-1': 'work',
'cal-2': 'work',
},
})
await flushPromises()
expect(wrapper.text()).toContain('Suggested 8.0 h')
})
it('applies the calendar suggestion when clicked in calendar goals mode', async () => {
postJsonMock.mockResolvedValue({
charts: {
perDaySeries: {
series: [
{ id: 'cal-1', data: [6] },
],
},
},
byCal: [{ id: 'cal-1', total_hours: 6 }],
})
const wrapper = mountWizard({
startStep: 'goals',
initialStrategy: 'total_plus_categories',
calendars: [
{ id: 'cal-1', displayname: 'Primary', color: '#ff0000' },
],
initialSelection: ['cal-1'],
})
await flushPromises()
const suggestion = wrapper.findAll('button').find((button) => button.text().includes('Suggested 6.0 h'))
expect(suggestion).toBeTruthy()
await suggestion?.trigger('click')
await flushPromises()
const targetInput = wrapper.find('.goal-calendar-row .input-unit input[type="number"]')
expect((targetInput.element as HTMLInputElement).value).toBe('6')
})
it('applies the category suggestion when clicked in granular mode', async () => {
postJsonMock.mockResolvedValue({
charts: {
perDaySeries: {
series: [
{ id: 'cal-1', data: [6] },
{ id: 'cal-2', data: [2] },
],
},
},
byCal: [
{ id: 'cal-1', total_hours: 6 },
{ id: 'cal-2', total_hours: 2 },
],
})
const wrapper = mountWizard({
startStep: 'goals',
initialStrategy: 'full_granular',
calendars: [
{ id: 'cal-1', displayname: 'Primary', color: '#ff0000' },
{ id: 'cal-2', displayname: 'Secondary', color: '#00ff00' },
],
initialSelection: ['cal-1', 'cal-2'],
initialCategories: [
{
id: 'work',
label: 'Work',
targetHours: 12,
includeWeekend: false,
paceMode: 'days_only',
color: '#2563EB',
},
],
initialAssignments: {
'cal-1': 'work',
'cal-2': 'work',
},
})
await flushPromises()
const suggestion = wrapper.findAll('button').find((button) => button.text().includes('Suggested 8.0 h'))
expect(suggestion).toBeTruthy()
await suggestion?.trigger('click')
await flushPromises()
const targetInput = wrapper.find('.goal-inline-target input[type="number"]')
expect((targetInput.element as HTMLInputElement).value).toBe('8')
})
it('auto-saves before closing the wizard', async () => {
const persistStep = vi.fn().mockResolvedValue(undefined)
const wrapper = mountWizard({
startStep: 'preferences',
persistStep,
})
await wrapper.find('.close-btn').trigger('click')
await flushPromises()
expect(persistStep).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('close')).toBeTruthy()
})
it('continues to review on dashboard card double click', async () => {
const wrapper = mountWizard({
startStep: 'dashboard',
initialDashboardMode: 'standard',
})
const card = wrapper.findAll('.dashboard-preset-card').find((node) => node.text().includes('Advanced'))
await card?.trigger('dblclick')
await flushPromises()
expect(wrapper.find('.step-arrow.current').text()).toContain('Review')
})
it('completes quick setup from intro with the default onboarding payload', async () => {
const wrapper = mountWizard({
hasExistingConfig: false,
initialSelection: [],
})
const quickCard = wrapper.findAll('.intro-route-card').find((card) => card.text().includes('Quick setup'))
await quickCard?.trigger('click')
const continueButton = wrapper.findAll('button').find((button) => button.text().includes('Continue'))
await continueButton?.trigger('click')
const complete = wrapper.emitted('complete')
expect(complete).toBeTruthy()
expect(complete?.[0]?.[0]?.strategy).toBe('total_plus_categories')
expect(complete?.[0]?.[0]?.dashboardMode).toBe('standard')
expect(complete?.[0]?.[0]?.selected).toEqual(['cal-1'])
})
it('renders the new onboarding step order', () => {
const wrapper = mountWizard()
const labels = wrapper.findAll('.step-arrow__label').map((node) => node.text())
expect(labels).toEqual(['Intro', 'Strategy', 'Calendars', 'Deck', 'Goals', 'Preferences', 'Dashboard', 'Review'])
})
it('removes category mix trend from single goal dashboard presets', async () => {
const wrapper = mountWizard({
startStep: 'review',
initialStrategy: 'total_only',
initialDashboardMode: 'pro',
})
const startButton = wrapper.findAll('button').find((button) => button.text().includes('Start dashboard'))
await startButton?.trigger('click')
const payload = wrapper.emitted('complete')?.[0]?.[0]
const widgetTypes = (payload?.widgets?.tabs || []).flatMap((tab: any) => (tab.widgets || []).map((widget: any) => widget.type))
expect(widgetTypes).not.toContain('category_mix_trend')
expect(widgetTypes).toContain('balance_index')
})
it('removes category mix trend from calendar goals dashboard presets', async () => {
const wrapper = mountWizard({
startStep: 'review',
initialStrategy: 'total_plus_categories',
initialDashboardMode: 'pro',
})
const startButton = wrapper.findAll('button').find((button) => button.text().includes('Start dashboard'))
await startButton?.trigger('click')
const payload = wrapper.emitted('complete')?.[0]?.[0]
const widgetTypes = (payload?.widgets?.tabs || []).flatMap((tab: any) => (tab.widgets || []).map((widget: any) => widget.type))
expect(widgetTypes).not.toContain('category_mix_trend')
expect(widgetTypes).toContain('balance_index')
expect(payload?.targetsConfig?.balance?.useCategoryMapping).toBe(false)
expect(payload?.targetsConfig?.balance?.index?.basis).toBe('calendar')
})
it('keeps category mix trend for calendar plus category goals', async () => {
const wrapper = mountWizard({
startStep: 'review',
initialStrategy: 'full_granular',
initialDashboardMode: 'pro',
})
const startButton = wrapper.findAll('button').find((button) => button.text().includes('Start dashboard'))
await startButton?.trigger('click')
const payload = wrapper.emitted('complete')?.[0]?.[0]
const widgetTypes = (payload?.widgets?.tabs || []).flatMap((tab: any) => (tab.widgets || []).map((widget: any) => widget.type))
expect(widgetTypes).toContain('category_mix_trend')
})
})