Merge branch 'worktree-agent-ad42524c'
This commit is contained in:
commit
e27718f450
2 changed files with 579 additions and 0 deletions
189
app/composables/useOnboarding.js
Normal file
189
app/composables/useOnboarding.js
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
/**
|
||||||
|
* Onboarding Composable
|
||||||
|
* Tracks new member onboarding goals and provides post-graduation suggestions.
|
||||||
|
*/
|
||||||
|
export function useOnboarding(options = {}) {
|
||||||
|
const goals = useState('onboarding.goals', () => ({
|
||||||
|
hasProfileTags: false,
|
||||||
|
hasVisitedEvent: false,
|
||||||
|
hasEngagedEcology: false,
|
||||||
|
hasClickedWiki: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const completedAt = useState('onboarding.completedAt', () => null)
|
||||||
|
const loading = useState('onboarding.loading', () => false)
|
||||||
|
const recommendations = useState('onboarding.recommendations', () => ({
|
||||||
|
events: [],
|
||||||
|
ecology: [],
|
||||||
|
wiki: [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Track whether we've already fetched status this session
|
||||||
|
const _fetched = useState('onboarding._fetched', () => false)
|
||||||
|
|
||||||
|
const isComplete = computed(() =>
|
||||||
|
goals.value.hasProfileTags &&
|
||||||
|
goals.value.hasVisitedEvent &&
|
||||||
|
goals.value.hasEngagedEcology &&
|
||||||
|
goals.value.hasClickedWiki
|
||||||
|
)
|
||||||
|
|
||||||
|
const pickCategory = options.pickCategory || ((categories) => {
|
||||||
|
return categories[Math.floor(Math.random() * categories.length)]
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentSuggestion = computed(() => {
|
||||||
|
// Not graduated — return highest-priority incomplete goal
|
||||||
|
if (!isComplete.value) {
|
||||||
|
if (!goals.value.hasProfileTags) {
|
||||||
|
return {
|
||||||
|
key: 'profileTags',
|
||||||
|
text: 'Complete your profile by adding your craft and community tags',
|
||||||
|
action: '/member/profile',
|
||||||
|
actionText: 'Set up tags',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!goals.value.hasVisitedEvent) {
|
||||||
|
return {
|
||||||
|
key: 'visitEvent',
|
||||||
|
text: 'Check out upcoming events',
|
||||||
|
action: '/events',
|
||||||
|
actionText: 'Browse events',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!goals.value.hasEngagedEcology) {
|
||||||
|
return {
|
||||||
|
key: 'ecology',
|
||||||
|
text: 'Explore the community ecology to find collaborators',
|
||||||
|
action: '/ecology',
|
||||||
|
actionText: 'Explore ecology',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!goals.value.hasClickedWiki) {
|
||||||
|
return {
|
||||||
|
key: 'wiki',
|
||||||
|
text: 'Browse the wiki for resources and guides',
|
||||||
|
action: null,
|
||||||
|
actionText: 'Browse wiki',
|
||||||
|
isExternal: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graduated — suggestion mode
|
||||||
|
const cats = ['events', 'ecology', 'wiki'].filter(
|
||||||
|
(c) => recommendations.value[c]?.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cats.length === 0) {
|
||||||
|
return { key: 'empty', text: 'No suggestions right now' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = pickCategory(cats)
|
||||||
|
const items = recommendations.value[selected]
|
||||||
|
|
||||||
|
if (items?.length > 0) {
|
||||||
|
return buildRecommendation(selected, items[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to first non-empty category (shouldn't happen since we filtered)
|
||||||
|
for (const cat of cats) {
|
||||||
|
if (recommendations.value[cat]?.length > 0) {
|
||||||
|
return buildRecommendation(cat, recommendations.value[cat][0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { key: 'empty', text: 'No suggestions right now' }
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildRecommendation(category, item) {
|
||||||
|
if (category === 'events') {
|
||||||
|
return {
|
||||||
|
key: 'event',
|
||||||
|
text: `Upcoming event: ${item.title}`,
|
||||||
|
action: `/events/${item._id}`,
|
||||||
|
actionText: 'View event',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (category === 'ecology') {
|
||||||
|
return {
|
||||||
|
key: 'ecology',
|
||||||
|
text: `Connect with ${item.name || 'a member'} in the ecology`,
|
||||||
|
action: '/ecology',
|
||||||
|
actionText: 'Explore ecology',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (category === 'wiki') {
|
||||||
|
return {
|
||||||
|
key: 'wiki',
|
||||||
|
text: `Recommended: ${item.title}`,
|
||||||
|
action: item.url || null,
|
||||||
|
actionText: 'Read article',
|
||||||
|
isExternal: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { key: 'empty', text: 'No suggestions right now' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
if (_fetched.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await $fetch('/api/onboarding/status')
|
||||||
|
if (data?.goals) {
|
||||||
|
goals.value = { ...goals.value, ...data.goals }
|
||||||
|
}
|
||||||
|
if (data?.completedAt) {
|
||||||
|
completedAt.value = data.completedAt
|
||||||
|
}
|
||||||
|
_fetched.value = true
|
||||||
|
|
||||||
|
// If graduated, fetch recommendations
|
||||||
|
if (isComplete.value) {
|
||||||
|
await fetchRecommendations()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently fail — goals stay at defaults
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRecommendations() {
|
||||||
|
const [events, ecology, wiki] = await Promise.allSettled([
|
||||||
|
$fetch('/api/events/recommended'),
|
||||||
|
$fetch('/api/ecology/suggestions'),
|
||||||
|
$fetch('/api/wiki/recommended'),
|
||||||
|
])
|
||||||
|
recommendations.value = {
|
||||||
|
events: events.status === 'fulfilled' ? (events.value || []) : [],
|
||||||
|
ecology: ecology.status === 'fulfilled' ? (ecology.value?.suggestions || []) : [],
|
||||||
|
wiki: wiki.status === 'fulfilled' ? (wiki.value || []) : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trackGoal(goalName) {
|
||||||
|
if (isComplete.value) return
|
||||||
|
try {
|
||||||
|
await $fetch('/api/onboarding/track', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { goal: goalName },
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Fire-and-forget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on first use
|
||||||
|
fetchStatus()
|
||||||
|
|
||||||
|
return {
|
||||||
|
goals: readonly(goals),
|
||||||
|
isComplete: readonly(isComplete),
|
||||||
|
completedAt: readonly(completedAt),
|
||||||
|
currentSuggestion,
|
||||||
|
trackGoal,
|
||||||
|
recommendations: readonly(recommendations),
|
||||||
|
loading: readonly(loading),
|
||||||
|
}
|
||||||
|
}
|
||||||
390
tests/client/composables/useOnboarding.test.js
Normal file
390
tests/client/composables/useOnboarding.test.js
Normal file
|
|
@ -0,0 +1,390 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { ref, computed, readonly } from 'vue'
|
||||||
|
|
||||||
|
// --- Nuxt auto-import stubs ---
|
||||||
|
|
||||||
|
vi.stubGlobal('computed', computed)
|
||||||
|
vi.stubGlobal('readonly', readonly)
|
||||||
|
|
||||||
|
// useState: return a fresh ref per key, reset between tests
|
||||||
|
const stateStore = {}
|
||||||
|
vi.stubGlobal('useState', (key, init) => {
|
||||||
|
if (!stateStore[key]) {
|
||||||
|
stateStore[key] = ref(init ? init() : null)
|
||||||
|
}
|
||||||
|
return stateStore[key]
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
for (const key of Object.keys(stateStore)) {
|
||||||
|
delete stateStore[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// $fetch mock
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
vi.stubGlobal('$fetch', fetchMock)
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
describe('useOnboarding', () => {
|
||||||
|
let useOnboarding
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetState()
|
||||||
|
fetchMock.mockReset()
|
||||||
|
// Default: status endpoint returns all-false goals
|
||||||
|
fetchMock.mockImplementation((url) => {
|
||||||
|
if (url === '/api/onboarding/status') {
|
||||||
|
return Promise.resolve({
|
||||||
|
goals: {
|
||||||
|
hasProfileTags: false,
|
||||||
|
hasVisitedEvent: false,
|
||||||
|
hasEngagedEcology: false,
|
||||||
|
hasClickedWiki: false,
|
||||||
|
},
|
||||||
|
completedAt: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-import to get clean module state
|
||||||
|
vi.resetModules()
|
||||||
|
const mod = await import('../../../app/composables/useOnboarding.js')
|
||||||
|
useOnboarding = mod.useOnboarding
|
||||||
|
})
|
||||||
|
|
||||||
|
// 9.1: Initializes goals from API response
|
||||||
|
it('9.1: initializes goals from API response', async () => {
|
||||||
|
fetchMock.mockImplementation((url) => {
|
||||||
|
if (url === '/api/onboarding/status') {
|
||||||
|
return Promise.resolve({
|
||||||
|
goals: {
|
||||||
|
hasProfileTags: true,
|
||||||
|
hasVisitedEvent: false,
|
||||||
|
hasEngagedEcology: true,
|
||||||
|
hasClickedWiki: false,
|
||||||
|
},
|
||||||
|
completedAt: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { goals, loading } = useOnboarding()
|
||||||
|
|
||||||
|
// Wait for fetchStatus to complete
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(goals.value.hasProfileTags).toBe(true)
|
||||||
|
expect(goals.value.hasVisitedEvent).toBe(false)
|
||||||
|
expect(goals.value.hasEngagedEcology).toBe(true)
|
||||||
|
expect(goals.value.hasClickedWiki).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 9.2: isComplete true when all goals true
|
||||||
|
it('9.2: isComplete is true when all goals are true', async () => {
|
||||||
|
fetchMock.mockImplementation((url) => {
|
||||||
|
if (url === '/api/onboarding/status') {
|
||||||
|
return Promise.resolve({
|
||||||
|
goals: {
|
||||||
|
hasProfileTags: true,
|
||||||
|
hasVisitedEvent: true,
|
||||||
|
hasEngagedEcology: true,
|
||||||
|
hasClickedWiki: true,
|
||||||
|
},
|
||||||
|
completedAt: '2026-04-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/events/recommended') return Promise.resolve([])
|
||||||
|
if (url === '/api/ecology/suggestions') return Promise.resolve({ suggestions: [] })
|
||||||
|
if (url === '/api/wiki/recommended') return Promise.resolve([])
|
||||||
|
return Promise.resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { isComplete, completedAt, loading } = useOnboarding()
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(isComplete.value).toBe(true)
|
||||||
|
expect(completedAt.value).toBe('2026-04-01T00:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 9.3: isComplete false with partial goals
|
||||||
|
it('9.3: isComplete is false with partial goals', async () => {
|
||||||
|
fetchMock.mockImplementation((url) => {
|
||||||
|
if (url === '/api/onboarding/status') {
|
||||||
|
return Promise.resolve({
|
||||||
|
goals: {
|
||||||
|
hasProfileTags: true,
|
||||||
|
hasVisitedEvent: true,
|
||||||
|
hasEngagedEcology: false,
|
||||||
|
hasClickedWiki: false,
|
||||||
|
},
|
||||||
|
completedAt: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { isComplete, loading } = useOnboarding()
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(isComplete.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 9.4: currentSuggestion returns profile tags when no tags set (priority 1)
|
||||||
|
it('9.4: currentSuggestion returns profile tags when no tags set', async () => {
|
||||||
|
const { currentSuggestion, loading } = useOnboarding()
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(currentSuggestion.value.key).toBe('profileTags')
|
||||||
|
expect(currentSuggestion.value.action).toBe('/member/profile')
|
||||||
|
expect(currentSuggestion.value.actionText).toBe('Set up tags')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 9.5: currentSuggestion returns event when tags set but no event visit (priority 2)
|
||||||
|
it('9.5: currentSuggestion returns event when tags set but no event visit', async () => {
|
||||||
|
fetchMock.mockImplementation((url) => {
|
||||||
|
if (url === '/api/onboarding/status') {
|
||||||
|
return Promise.resolve({
|
||||||
|
goals: {
|
||||||
|
hasProfileTags: true,
|
||||||
|
hasVisitedEvent: false,
|
||||||
|
hasEngagedEcology: false,
|
||||||
|
hasClickedWiki: false,
|
||||||
|
},
|
||||||
|
completedAt: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { currentSuggestion, loading } = useOnboarding()
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(currentSuggestion.value.key).toBe('visitEvent')
|
||||||
|
expect(currentSuggestion.value.action).toBe('/events')
|
||||||
|
expect(currentSuggestion.value.actionText).toBe('Browse events')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 9.6: currentSuggestion returns ecology when tags + event done (priority 3)
|
||||||
|
it('9.6: currentSuggestion returns ecology when tags + event done', async () => {
|
||||||
|
fetchMock.mockImplementation((url) => {
|
||||||
|
if (url === '/api/onboarding/status') {
|
||||||
|
return Promise.resolve({
|
||||||
|
goals: {
|
||||||
|
hasProfileTags: true,
|
||||||
|
hasVisitedEvent: true,
|
||||||
|
hasEngagedEcology: false,
|
||||||
|
hasClickedWiki: false,
|
||||||
|
},
|
||||||
|
completedAt: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { currentSuggestion, loading } = useOnboarding()
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(currentSuggestion.value.key).toBe('ecology')
|
||||||
|
expect(currentSuggestion.value.action).toBe('/ecology')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 9.7: currentSuggestion returns wiki when only wiki remaining (priority 4)
|
||||||
|
it('9.7: currentSuggestion returns wiki when only wiki remaining', async () => {
|
||||||
|
fetchMock.mockImplementation((url) => {
|
||||||
|
if (url === '/api/onboarding/status') {
|
||||||
|
return Promise.resolve({
|
||||||
|
goals: {
|
||||||
|
hasProfileTags: true,
|
||||||
|
hasVisitedEvent: true,
|
||||||
|
hasEngagedEcology: true,
|
||||||
|
hasClickedWiki: false,
|
||||||
|
},
|
||||||
|
completedAt: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { currentSuggestion, loading } = useOnboarding()
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(currentSuggestion.value.key).toBe('wiki')
|
||||||
|
expect(currentSuggestion.value.action).toBeNull()
|
||||||
|
expect(currentSuggestion.value.isExternal).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 9.8: trackGoal fires POST and is skipped when complete
|
||||||
|
it('9.8: trackGoal fires POST and is skipped when complete', async () => {
|
||||||
|
fetchMock.mockImplementation((url) => {
|
||||||
|
if (url === '/api/onboarding/status') {
|
||||||
|
return Promise.resolve({
|
||||||
|
goals: {
|
||||||
|
hasProfileTags: true,
|
||||||
|
hasVisitedEvent: true,
|
||||||
|
hasEngagedEcology: true,
|
||||||
|
hasClickedWiki: true,
|
||||||
|
},
|
||||||
|
completedAt: '2026-04-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/events/recommended') return Promise.resolve([])
|
||||||
|
if (url === '/api/ecology/suggestions') return Promise.resolve({ suggestions: [] })
|
||||||
|
if (url === '/api/wiki/recommended') return Promise.resolve([])
|
||||||
|
return Promise.resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { trackGoal, loading } = useOnboarding()
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
await trackGoal('eventPageVisited')
|
||||||
|
|
||||||
|
// Should NOT have called the track endpoint since isComplete is true
|
||||||
|
const trackCalls = fetchMock.mock.calls.filter(
|
||||||
|
(c) => c[0] === '/api/onboarding/track'
|
||||||
|
)
|
||||||
|
expect(trackCalls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 9.9: Suggestion mode uses injectable pickCategory
|
||||||
|
it('9.9: suggestion mode uses injectable pickCategory', async () => {
|
||||||
|
fetchMock.mockImplementation((url) => {
|
||||||
|
if (url === '/api/onboarding/status') {
|
||||||
|
return Promise.resolve({
|
||||||
|
goals: {
|
||||||
|
hasProfileTags: true,
|
||||||
|
hasVisitedEvent: true,
|
||||||
|
hasEngagedEcology: true,
|
||||||
|
hasClickedWiki: true,
|
||||||
|
},
|
||||||
|
completedAt: '2026-04-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/events/recommended') {
|
||||||
|
return Promise.resolve([{ _id: 'e1', title: 'Game Jam' }])
|
||||||
|
}
|
||||||
|
if (url === '/api/ecology/suggestions') {
|
||||||
|
return Promise.resolve({ suggestions: [{ name: 'Alex' }] })
|
||||||
|
}
|
||||||
|
if (url === '/api/wiki/recommended') {
|
||||||
|
return Promise.resolve([{ title: 'Co-op Guide', url: 'https://wiki.example.com/coop' }])
|
||||||
|
}
|
||||||
|
return Promise.resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Always pick 'wiki'
|
||||||
|
const { currentSuggestion, loading } = useOnboarding({
|
||||||
|
pickCategory: () => 'wiki',
|
||||||
|
})
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(currentSuggestion.value.key).toBe('wiki')
|
||||||
|
expect(currentSuggestion.value.text).toContain('Co-op Guide')
|
||||||
|
expect(currentSuggestion.value.isExternal).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 9.10: Suggestion mode falls back when selected category empty
|
||||||
|
it('9.10: suggestion mode falls back when selected category empty', async () => {
|
||||||
|
fetchMock.mockImplementation((url) => {
|
||||||
|
if (url === '/api/onboarding/status') {
|
||||||
|
return Promise.resolve({
|
||||||
|
goals: {
|
||||||
|
hasProfileTags: true,
|
||||||
|
hasVisitedEvent: true,
|
||||||
|
hasEngagedEcology: true,
|
||||||
|
hasClickedWiki: true,
|
||||||
|
},
|
||||||
|
completedAt: '2026-04-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/events/recommended') {
|
||||||
|
return Promise.resolve([{ _id: 'e1', title: 'Game Jam' }])
|
||||||
|
}
|
||||||
|
if (url === '/api/ecology/suggestions') {
|
||||||
|
return Promise.resolve({ suggestions: [] })
|
||||||
|
}
|
||||||
|
if (url === '/api/wiki/recommended') {
|
||||||
|
return Promise.resolve([])
|
||||||
|
}
|
||||||
|
return Promise.resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// pickCategory only gets non-empty categories, so it should only see 'events'
|
||||||
|
const pickedCategories = []
|
||||||
|
const { currentSuggestion, loading } = useOnboarding({
|
||||||
|
pickCategory: (cats) => {
|
||||||
|
pickedCategories.push([...cats])
|
||||||
|
return cats[0]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Access computed first to trigger pickCategory
|
||||||
|
expect(currentSuggestion.value.key).toBe('event')
|
||||||
|
expect(currentSuggestion.value.text).toContain('Game Jam')
|
||||||
|
// Only events had results, so pickCategory should only have received ['events']
|
||||||
|
expect(pickedCategories[0]).toEqual(['events'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// 9.11: Suggestion mode shows fallback when all categories empty
|
||||||
|
it('9.11: suggestion mode shows fallback when all categories empty', async () => {
|
||||||
|
fetchMock.mockImplementation((url) => {
|
||||||
|
if (url === '/api/onboarding/status') {
|
||||||
|
return Promise.resolve({
|
||||||
|
goals: {
|
||||||
|
hasProfileTags: true,
|
||||||
|
hasVisitedEvent: true,
|
||||||
|
hasEngagedEcology: true,
|
||||||
|
hasClickedWiki: true,
|
||||||
|
},
|
||||||
|
completedAt: '2026-04-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/events/recommended') return Promise.resolve([])
|
||||||
|
if (url === '/api/ecology/suggestions') return Promise.resolve({ suggestions: [] })
|
||||||
|
if (url === '/api/wiki/recommended') return Promise.resolve([])
|
||||||
|
return Promise.resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { currentSuggestion, loading } = useOnboarding()
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(currentSuggestion.value.key).toBe('empty')
|
||||||
|
expect(currentSuggestion.value.text).toBe('No suggestions right now')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue