parent
6009ce5ec2
commit
0b271bc352
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte'
|
||||
import AlertModal from './AlertModal.svelte'
|
||||
|
||||
describe('AlertModal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
render(AlertModal, {
|
||||
props: { open: false, title: 'Notice', message: 'Hello' },
|
||||
})
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders title and message when open', () => {
|
||||
render(AlertModal, {
|
||||
props: {
|
||||
open: true,
|
||||
title: 'Error',
|
||||
message: 'Something went wrong.',
|
||||
},
|
||||
})
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Error')).toBeInTheDocument()
|
||||
expect(screen.getByText('Something went wrong.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClose when OK is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(AlertModal, {
|
||||
props: {
|
||||
open: true,
|
||||
title: 'Notice',
|
||||
message: 'Done.',
|
||||
okLabel: 'OK',
|
||||
onClose,
|
||||
},
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'OK' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onClose on Escape key', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(AlertModal, {
|
||||
props: { open: true, title: 'Notice', message: 'Hi', onClose },
|
||||
})
|
||||
const dialog = screen.getByRole('alertdialog')
|
||||
await fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onClose on Enter key', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(AlertModal, {
|
||||
props: { open: true, title: 'Notice', message: 'Hi', onClose },
|
||||
})
|
||||
const dialog = screen.getByRole('alertdialog')
|
||||
await fireEvent.keyDown(dialog, { key: 'Enter' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onClose when backdrop is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
const { container } = render(AlertModal, {
|
||||
props: { open: true, title: 'Notice', message: 'Hi', onClose },
|
||||
})
|
||||
const backdrop = container.querySelector('.modal-backdrop')
|
||||
expect(backdrop).toBeTruthy()
|
||||
await fireEvent.click(backdrop)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,32 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte'
|
||||
import Card from './Card.svelte'
|
||||
|
||||
describe('Card', () => {
|
||||
it('renders card title, description, and image', () => {
|
||||
const card = {
|
||||
title: 'My Deck',
|
||||
description: 'A cool flashcard deck.',
|
||||
imageUrl: 'https://example.com/img.png',
|
||||
}
|
||||
render(Card, { props: { card } })
|
||||
expect(screen.getByText('My Deck')).toBeInTheDocument()
|
||||
expect(screen.getByText('A cool flashcard deck.')).toBeInTheDocument()
|
||||
const img = screen.getByRole('img', { name: 'My Deck' })
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/img.png')
|
||||
})
|
||||
|
||||
it('calls useCard when Use button is clicked', async () => {
|
||||
const card = {
|
||||
title: 'Test Deck',
|
||||
description: 'Desc',
|
||||
imageUrl: '/img.png',
|
||||
}
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
render(Card, { props: { card } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Use' }))
|
||||
expect(logSpy).toHaveBeenCalledWith('Test Deck')
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte'
|
||||
import ConfirmModal from './ConfirmModal.svelte'
|
||||
|
||||
describe('ConfirmModal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
render(ConfirmModal, {
|
||||
props: { open: false, title: 'Confirm', message: 'Are you sure?' },
|
||||
})
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders title and message when open', () => {
|
||||
render(ConfirmModal, {
|
||||
props: {
|
||||
open: true,
|
||||
title: 'Delete deck',
|
||||
message: 'This cannot be undone.',
|
||||
},
|
||||
})
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Delete deck')).toBeInTheDocument()
|
||||
expect(screen.getByText('This cannot be undone.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm when Confirm is clicked', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(ConfirmModal, {
|
||||
props: {
|
||||
open: true,
|
||||
title: 'Confirm',
|
||||
message: 'Proceed?',
|
||||
confirmLabel: 'Yes',
|
||||
onConfirm,
|
||||
},
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Yes' }))
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when Cancel is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
render(ConfirmModal, {
|
||||
props: {
|
||||
open: true,
|
||||
title: 'Confirm',
|
||||
message: 'Proceed?',
|
||||
cancelLabel: 'No',
|
||||
onCancel,
|
||||
},
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'No' }))
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel on Escape key', async () => {
|
||||
const onCancel = vi.fn()
|
||||
render(ConfirmModal, {
|
||||
props: { open: true, title: 'Confirm', message: 'Proceed?', onCancel },
|
||||
})
|
||||
const dialog = screen.getByRole('dialog')
|
||||
await fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when backdrop is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
const { container } = render(ConfirmModal, {
|
||||
props: { open: true, title: 'Confirm', message: 'Proceed?', onCancel },
|
||||
})
|
||||
const backdrop = container.querySelector('.modal-backdrop')
|
||||
expect(backdrop).toBeTruthy()
|
||||
await fireEvent.click(backdrop)
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte'
|
||||
import Counter from './Counter.svelte'
|
||||
|
||||
describe('Counter', () => {
|
||||
it('starts at 0 and increments on click', async () => {
|
||||
render(Counter)
|
||||
const btn = screen.getByRole('button', { name: /count is 0/i })
|
||||
expect(btn).toBeInTheDocument()
|
||||
await fireEvent.click(btn)
|
||||
expect(screen.getByRole('button', { name: /count is 1/i })).toBeInTheDocument()
|
||||
await fireEvent.click(screen.getByRole('button', { name: /count is 1/i }))
|
||||
expect(screen.getByRole('button', { name: /count is 2/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/svelte'
|
||||
import Footer from './Footer.svelte'
|
||||
|
||||
describe('Footer', () => {
|
||||
it('renders brand name and copyright with current year', () => {
|
||||
render(Footer)
|
||||
expect(screen.getByText('Omotomo')).toBeInTheDocument()
|
||||
const year = new Date().getFullYear()
|
||||
expect(screen.getByText(new RegExp(`©\\s*${year}`))).toBeInTheDocument()
|
||||
expect(screen.getByText(/All rights reserved/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import Navbar from './Navbar.svelte'
|
||||
import { auth } from './stores/auth.js'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
const locationStore = writable('/')
|
||||
vi.mock('svelte-spa-router', () => ({
|
||||
push: (...args) => mockPush(...args),
|
||||
location: { subscribe: (...args) => locationStore.subscribe(...args) },
|
||||
}))
|
||||
|
||||
vi.mock('./stores/auth.js', () => {
|
||||
const { writable } = require('svelte/store')
|
||||
const authStore = writable({ user: null, loading: false, error: null })
|
||||
return {
|
||||
auth: {
|
||||
subscribe: authStore.subscribe,
|
||||
set: authStore.set,
|
||||
clearError: vi.fn(),
|
||||
login: vi.fn().mockResolvedValue(true),
|
||||
register: vi.fn().mockResolvedValue({ success: true, needsConfirmation: false }),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./api/profile.js', () => ({
|
||||
getProfile: vi.fn().mockResolvedValue({ id: 'u1', display_name: 'Test User', email: 'test@example.com' }),
|
||||
}))
|
||||
|
||||
describe('Navbar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
auth.set({ user: null, loading: false, error: null })
|
||||
vi.mocked(auth.login).mockResolvedValue(true)
|
||||
vi.mocked(auth.register).mockResolvedValue({ success: true, needsConfirmation: false })
|
||||
})
|
||||
|
||||
describe('when logged out', () => {
|
||||
it('shows Login button and Community link', () => {
|
||||
render(Navbar)
|
||||
expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Community/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('openPopup: clicking Login opens auth popup', async () => {
|
||||
render(Navbar)
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Login' }))
|
||||
expect(auth.clearError).toHaveBeenCalled()
|
||||
expect(screen.getByRole('dialog', { name: /Sign in/i })).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Email or username')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closePopup: Close button closes popup', async () => {
|
||||
render(Navbar)
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Login' }))
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handleBackdropClick: clicking backdrop closes popup', async () => {
|
||||
const { container } = render(Navbar)
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Login' }))
|
||||
const backdrop = container.querySelector('.popup-backdrop')
|
||||
await fireEvent.click(backdrop)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handleSubmit (login): submit form calls auth.login and closes on success', async () => {
|
||||
render(Navbar)
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Login' }))
|
||||
const form = screen.getByRole('dialog').querySelector('form')
|
||||
await fireEvent.input(screen.getByPlaceholderText('Email or username'), { target: { value: 'a@b.com' } })
|
||||
await fireEvent.input(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } })
|
||||
await fireEvent.submit(form)
|
||||
expect(auth.login).toHaveBeenCalledWith('a@b.com', 'secret')
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('switch to Register tab and handleSubmit (register)', async () => {
|
||||
render(Navbar)
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Login' }))
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Register' }))
|
||||
expect(screen.getByRole('dialog', { name: /Register/i })).toBeInTheDocument()
|
||||
await fireEvent.input(screen.getByPlaceholderText('Email'), { target: { value: 'new@b.com' } })
|
||||
await fireEvent.input(screen.getByPlaceholderText('Username'), { target: { value: 'newuser' } })
|
||||
await fireEvent.input(screen.getByPlaceholderText('Password'), { target: { value: 'secret' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Create account' }))
|
||||
expect(auth.register).toHaveBeenCalledWith('new@b.com', 'secret', 'newuser')
|
||||
})
|
||||
|
||||
it('handleSubmit returns early when email or password empty', async () => {
|
||||
render(Navbar)
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Login' }))
|
||||
const form = screen.getByRole('dialog').querySelector('form')
|
||||
await fireEvent.submit(form)
|
||||
expect(auth.login).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when logged in', () => {
|
||||
beforeEach(() => {
|
||||
auth.set({ user: { id: 'u1' }, loading: false, error: null })
|
||||
})
|
||||
|
||||
it('shows My decks, Community, Create deck and user menu', () => {
|
||||
render(Navbar)
|
||||
expect(screen.getByRole('link', { name: /My decks/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Create deck' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'User menu' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggleUserMenu: opens dropdown and fetches profile', async () => {
|
||||
const { getProfile } = await import('./api/profile.js')
|
||||
render(Navbar)
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'User menu' }))
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Settings' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Logout' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(getProfile).toHaveBeenCalledWith('u1'))
|
||||
})
|
||||
|
||||
it('goSettings: Settings link calls push and closes menu', async () => {
|
||||
render(Navbar)
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'User menu' }))
|
||||
await fireEvent.click(screen.getByRole('menuitem', { name: 'Settings' }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/settings')
|
||||
})
|
||||
|
||||
it('handleLogout: Logout calls auth.logout and push', async () => {
|
||||
render(Navbar)
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'User menu' }))
|
||||
await fireEvent.click(screen.getByRole('menuitem', { name: 'Logout' }))
|
||||
expect(auth.logout).toHaveBeenCalled()
|
||||
expect(mockPush).toHaveBeenCalledWith('/')
|
||||
})
|
||||
|
||||
it('handleClickOutside: click outside user menu closes it', async () => {
|
||||
render(Navbar)
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'User menu' }))
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument()
|
||||
await fireEvent.click(document.body)
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('onProfileUpdated: profile-updated event updates profile', async () => {
|
||||
auth.set({ user: { id: 'u1' }, loading: false, error: null })
|
||||
render(Navbar)
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'User menu' }))
|
||||
await waitFor(() => expect(screen.getByText('Test User')).toBeInTheDocument())
|
||||
window.dispatchEvent(new CustomEvent('profile-updated', { detail: { id: 'u1', display_name: 'Updated', email: 'u@u.com' } }))
|
||||
await waitFor(() => expect(screen.getByText('Updated')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('brand and Community link call push', async () => {
|
||||
render(Navbar)
|
||||
await fireEvent.click(screen.getByRole('link', { name: /Omotomo/i }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/')
|
||||
await fireEvent.click(screen.getByRole('link', { name: /Community/i }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/community')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte'
|
||||
import QuestionEditor from './QuestionEditor.svelte'
|
||||
|
||||
describe('QuestionEditor', () => {
|
||||
it('renders question index and prompt placeholder', () => {
|
||||
const question = { prompt: '', explanation: '', answers: [''], correct_answer_indices: [0] }
|
||||
render(QuestionEditor, { props: { question, index: 0 } })
|
||||
expect(screen.getByText('Question 1')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Prompt')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Explanation (optional)')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Answer 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRemove when Remove question is clicked', async () => {
|
||||
const question = { prompt: '', explanation: '', answers: [''], correct_answer_indices: [0] }
|
||||
const onRemove = vi.fn()
|
||||
render(QuestionEditor, { props: { question, index: 0, onRemove } })
|
||||
await fireEvent.click(screen.getByTitle('Remove question'))
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('adds answer row when Add answer is clicked', async () => {
|
||||
const question = { prompt: '', explanation: '', answers: [''], correct_answer_indices: [0] }
|
||||
render(QuestionEditor, { props: { question, index: 0 } })
|
||||
expect(screen.getByPlaceholderText('Answer 1')).toBeInTheDocument()
|
||||
await fireEvent.click(screen.getByRole('button', { name: '+ Add answer' }))
|
||||
expect(screen.getByPlaceholderText('Answer 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows remove answer button when there is more than one answer', () => {
|
||||
const question = {
|
||||
prompt: '',
|
||||
explanation: '',
|
||||
answers: ['A', 'B'],
|
||||
correct_answer_indices: [0],
|
||||
}
|
||||
render(QuestionEditor, { props: { question, index: 0 } })
|
||||
const removeButtons = screen.getAllByTitle('Remove answer')
|
||||
expect(removeButtons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('updates answer text on input', async () => {
|
||||
const question = { prompt: '', explanation: '', answers: [''], correct_answer_indices: [0] }
|
||||
render(QuestionEditor, { props: { question, index: 0 } })
|
||||
const input = screen.getByPlaceholderText('Answer 1')
|
||||
await fireEvent.input(input, { target: { value: 'New answer' } })
|
||||
expect(question.answers[0]).toBe('New answer')
|
||||
})
|
||||
|
||||
it('toggles correct checkbox', async () => {
|
||||
const question = { prompt: '', explanation: '', answers: ['A', 'B'], correct_answer_indices: [0] }
|
||||
render(QuestionEditor, { props: { question, index: 0 } })
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
expect(checkboxes.length).toBeGreaterThanOrEqual(2)
|
||||
await fireEvent.click(checkboxes[1])
|
||||
expect(question.correct_answer_indices).toContain(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte'
|
||||
import RatingModal from './RatingModal.svelte'
|
||||
|
||||
describe('RatingModal', () => {
|
||||
const deck = { id: 'deck-1', title: 'Cool Deck' }
|
||||
|
||||
it('renders nothing when deck is null', () => {
|
||||
render(RatingModal, { props: { deck: null } })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders title and star buttons when deck is set', () => {
|
||||
render(RatingModal, { props: { deck } })
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Rate: Cool Deck/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '1 star' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '5 stars' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Submit is disabled when no stars selected', () => {
|
||||
render(RatingModal, { props: { deck } })
|
||||
expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('selecting stars enables Submit and calls onSubmit with rating and comment', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(RatingModal, {
|
||||
props: { deck, onSubmit, onClose },
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: '3 stars' }))
|
||||
expect(screen.getByRole('button', { name: 'Submit' })).not.toBeDisabled()
|
||||
const textarea = screen.getByPlaceholderText('Add a comment…')
|
||||
await fireEvent.input(textarea, { target: { value: ' Great deck!' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ rating: 3, comment: 'Great deck!' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('Cancel calls onClose', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(RatingModal, { props: { deck, onClose } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('Escape key calls onClose', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(RatingModal, { props: { deck, onClose } })
|
||||
const dialog = screen.getByRole('dialog')
|
||||
await fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses initialRating and initialComment when deck is set', () => {
|
||||
render(RatingModal, {
|
||||
props: {
|
||||
deck,
|
||||
initialRating: 4,
|
||||
initialComment: 'Already had a comment',
|
||||
},
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'Submit' })).not.toBeDisabled()
|
||||
expect(screen.getByDisplayValue('Already had a comment')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits with null comment when comment is empty or whitespace', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(RatingModal, { props: { deck, onSubmit, onClose } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: '2 stars' }))
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ rating: 2, comment: null })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte'
|
||||
import ReviewsModal from './ReviewsModal.svelte'
|
||||
|
||||
describe('ReviewsModal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
render(ReviewsModal, {
|
||||
props: { open: false, deckTitle: 'Deck', reviews: [] },
|
||||
})
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loading message when loading', () => {
|
||||
render(ReviewsModal, {
|
||||
props: { open: true, deckTitle: 'My Deck', loading: true, reviews: [] },
|
||||
})
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Reviews: My Deck/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "No reviews yet" when not loading and reviews empty', () => {
|
||||
render(ReviewsModal, {
|
||||
props: { open: true, deckTitle: 'Deck', loading: false, reviews: [] },
|
||||
})
|
||||
expect(screen.getByText('No reviews yet.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders review list with reviewer name and comment', () => {
|
||||
const reviews = [
|
||||
{
|
||||
user_id: 'u1',
|
||||
rating: 5,
|
||||
comment: 'Great deck!',
|
||||
display_name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
{
|
||||
user_id: 'u2',
|
||||
rating: 3,
|
||||
comment: null,
|
||||
display_name: null,
|
||||
email: 'bob@example.com',
|
||||
},
|
||||
{
|
||||
user_id: 'u3',
|
||||
rating: 4,
|
||||
comment: null,
|
||||
display_name: null,
|
||||
email: null,
|
||||
},
|
||||
{
|
||||
user_id: 'u4',
|
||||
rating: 2,
|
||||
comment: 'Could be better',
|
||||
display_name: ' ',
|
||||
email: null,
|
||||
},
|
||||
]
|
||||
render(ReviewsModal, {
|
||||
props: { open: true, deckTitle: 'Deck', loading: false, reviews },
|
||||
})
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||
expect(screen.getByText('Great deck!')).toBeInTheDocument()
|
||||
expect(screen.getByText('bob@example.com')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Anonymous').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('Could be better')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClose when Close is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(ReviewsModal, {
|
||||
props: { open: true, deckTitle: 'Deck', loading: false, reviews: [], onClose },
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onClose on Escape key', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(ReviewsModal, {
|
||||
props: { open: true, deckTitle: 'Deck', reviews: [], onClose },
|
||||
})
|
||||
const dialog = screen.getByRole('dialog')
|
||||
await fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onClose when backdrop is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
const { container } = render(ReviewsModal, {
|
||||
props: { open: true, deckTitle: 'Deck', reviews: [], onClose },
|
||||
})
|
||||
const backdrop = container.querySelector('.modal-backdrop')
|
||||
await fireEvent.click(backdrop)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as decksApi from './decks.js'
|
||||
|
||||
const nextResults = []
|
||||
const countResults = []
|
||||
function createChain() {
|
||||
const getResult = () => nextResults.shift() ?? { data: null, error: null }
|
||||
const getCount = () => countResults.shift() ?? { count: 0, error: null }
|
||||
const countThenable = {
|
||||
eq: vi.fn(function () { return this }),
|
||||
then(resolve) { return Promise.resolve(getCount()).then(resolve) },
|
||||
catch(fn) { return Promise.resolve(getCount()).catch(fn) },
|
||||
}
|
||||
const chain = {
|
||||
from: vi.fn(function () { return this }),
|
||||
select: vi.fn(function (cols, opts) {
|
||||
if (opts?.count === 'exact' && opts?.head) return countThenable
|
||||
return this
|
||||
}),
|
||||
eq: vi.fn(function () { return this }),
|
||||
order: vi.fn(function () { return this }),
|
||||
in: vi.fn(function () { return this }),
|
||||
not: vi.fn(function () { return this }),
|
||||
limit: vi.fn(function () { return this }),
|
||||
or: vi.fn(function () { return this }),
|
||||
single: vi.fn(() => Promise.resolve(getResult())),
|
||||
insert: vi.fn(function () { return this }),
|
||||
update: vi.fn(function () { return this }),
|
||||
delete: vi.fn(function () { return this }),
|
||||
maybeSingle: vi.fn(() => Promise.resolve(getResult())),
|
||||
upsert: vi.fn(() => Promise.resolve(getResult())),
|
||||
then(resolve) { return Promise.resolve(getResult()).then(resolve) },
|
||||
catch(fn) { return Promise.resolve(getResult()).catch(fn) },
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
let fromImpl = () => createChain()
|
||||
vi.mock('../supabase.js', () => ({
|
||||
supabase: {
|
||||
from: vi.fn((...args) => fromImpl(...args)),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('decks API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
nextResults.length = 0
|
||||
countResults.length = 0
|
||||
fromImpl = () => createChain()
|
||||
})
|
||||
|
||||
describe('fetchMyDecks', () => {
|
||||
it('returns empty array when no decks', async () => {
|
||||
nextResults.push({ data: [], error: null })
|
||||
const result = await decksApi.fetchMyDecks('user1')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns decks with question_count and needs_update', async () => {
|
||||
const decks = [
|
||||
{ id: 'd1', title: 'Deck 1', copied_from_deck_id: null, copied_from_version: null, updated_at: '2025-01-01' },
|
||||
]
|
||||
nextResults.push({ data: decks, error: null }, { data: [], error: null }, { data: [], error: null })
|
||||
countResults.push({ count: 3, error: null })
|
||||
const result = await decksApi.fetchMyDecks('user1')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].question_count).toBe(3)
|
||||
expect(result[0].title).toBe('Deck 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchPublishedDecks', () => {
|
||||
it('returns empty array when no published decks', async () => {
|
||||
nextResults.push({ data: [], error: null })
|
||||
const result = await decksApi.fetchPublishedDecks()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchDeckWithQuestions', () => {
|
||||
it('returns deck with questions', async () => {
|
||||
const deck = { id: 'd1', title: 'Deck', version: 1 }
|
||||
const questions = [{ id: 'q1', prompt: 'Q?', sort_order: 0 }]
|
||||
nextResults.push({ data: deck, error: null }, { data: questions, error: null })
|
||||
const result = await decksApi.fetchDeckWithQuestions('d1')
|
||||
expect(result).toMatchObject({ ...deck, questions })
|
||||
expect(result.questions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('throws when deck not found', async () => {
|
||||
nextResults.push({ data: null, error: new Error('Not found') })
|
||||
await expect(decksApi.fetchDeckWithQuestions('bad')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('userHasDeck', () => {
|
||||
it('returns false when userId is null', async () => {
|
||||
expect(await decksApi.userHasDeck('d1', null)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when user has deck', async () => {
|
||||
nextResults.push({ data: [{ id: 'd1' }], error: null })
|
||||
expect(await decksApi.userHasDeck('d1', 'user1')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no match', async () => {
|
||||
nextResults.push({ data: [], error: null })
|
||||
expect(await decksApi.userHasDeck('d1', 'user1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('togglePublished', () => {
|
||||
it('calls update and throws on error', async () => {
|
||||
nextResults.push({ error: new Error('DB error') })
|
||||
await expect(decksApi.togglePublished('d1', true)).rejects.toThrow('DB error')
|
||||
})
|
||||
|
||||
it('succeeds when no error', async () => {
|
||||
nextResults.push({ error: null })
|
||||
await expect(decksApi.togglePublished('d1', false)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteDeck', () => {
|
||||
it('throws on error', async () => {
|
||||
nextResults.push({ error: new Error('FK violation') })
|
||||
await expect(decksApi.deleteDeck('d1')).rejects.toThrow('FK violation')
|
||||
})
|
||||
|
||||
it('succeeds when no error', async () => {
|
||||
nextResults.push({ error: null })
|
||||
await expect(decksApi.deleteDeck('d1')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMyDeckRating', () => {
|
||||
it('returns null when userId is null', async () => {
|
||||
expect(await decksApi.getMyDeckRating('d1', null)).toBe(null)
|
||||
})
|
||||
|
||||
it('returns rating and comment when present', async () => {
|
||||
nextResults.push({ data: { rating: 4, comment: 'Great!' }, error: null })
|
||||
const result = await decksApi.getMyDeckRating('d1', 'user1')
|
||||
expect(result).toEqual({ rating: 4, comment: 'Great!' })
|
||||
})
|
||||
|
||||
it('returns null when no rating', async () => {
|
||||
nextResults.push({ data: null, error: null })
|
||||
expect(await decksApi.getMyDeckRating('d1', 'user1')).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitDeckRating', () => {
|
||||
it('clamps rating to 1-5 and trims comment', async () => {
|
||||
nextResults.push({ error: null })
|
||||
await decksApi.submitDeckRating('d1', 'user1', { rating: 10, comment: ' ok ' })
|
||||
expect(nextResults.length).toBe(0)
|
||||
})
|
||||
|
||||
it('throws on error', async () => {
|
||||
nextResults.push({ error: new Error('Unique violation') })
|
||||
await expect(decksApi.submitDeckRating('d1', 'user1', { rating: 3 })).rejects.toThrow('Unique violation')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDeckReviews', () => {
|
||||
it('returns empty array when no ratings', async () => {
|
||||
nextResults.push({ data: [], error: null })
|
||||
const result = await decksApi.getDeckReviews('d1')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns reviews with display names from profiles', async () => {
|
||||
nextResults.push(
|
||||
{ data: [{ user_id: 'u1', rating: 5, comment: 'Nice', created_at: '2025-01-01' }], error: null },
|
||||
{ data: [{ id: 'u1', display_name: 'Alice', email: 'a@b.com', avatar_url: null }], error: null }
|
||||
)
|
||||
const result = await decksApi.getDeckReviews('d1')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({ rating: 5, comment: 'Nice', display_name: 'Alice', email: 'a@b.com' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDeck', () => {
|
||||
it('creates deck and returns id', async () => {
|
||||
nextResults.push({ data: { id: 'new-id' }, error: null })
|
||||
const id = await decksApi.createDeck('user1', { title: 'Title', description: 'Desc', config: {} })
|
||||
expect(id).toBe('new-id')
|
||||
})
|
||||
|
||||
it('throws when insert fails', async () => {
|
||||
nextResults.push({ data: null, error: new Error('Failed') })
|
||||
await expect(decksApi.createDeck('user1', { title: 'T', description: '', config: {} })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateDeck', () => {
|
||||
it('fetches current version then updates', async () => {
|
||||
nextResults.push(
|
||||
{ data: { version: 1, published: false }, error: null },
|
||||
{ error: null },
|
||||
{ error: null }
|
||||
)
|
||||
await decksApi.updateDeck('d1', { title: 'New', description: '', config: {}, questions: [] })
|
||||
expect(nextResults.length).toBe(0)
|
||||
})
|
||||
|
||||
it('bumps version when published', async () => {
|
||||
nextResults.push(
|
||||
{ data: { version: 2, published: true }, error: null },
|
||||
{ error: null },
|
||||
{ error: null }
|
||||
)
|
||||
await decksApi.updateDeck('d1', { title: 'T', description: '', config: {}, questions: [] })
|
||||
expect(nextResults.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyDeckToUser', () => {
|
||||
it('throws when deck not published', async () => {
|
||||
nextResults.push(
|
||||
{ data: { id: 's1', title: 'S', published: false, questions: [] }, error: null },
|
||||
{ data: [], error: null }
|
||||
)
|
||||
await expect(decksApi.copyDeckToUser('s1', 'user1')).rejects.toThrow('not available to copy')
|
||||
})
|
||||
|
||||
it('creates copy and returns new deck id', async () => {
|
||||
nextResults.push(
|
||||
{ data: { id: 's1', title: 'Source', published: true, version: 1, description: '', config: {}, questions: [] }, error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: { id: 'new-id' }, error: null }
|
||||
)
|
||||
const id = await decksApi.copyDeckToUser('s1', 'user1')
|
||||
expect(id).toBe('new-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSourceUpdatePreview', () => {
|
||||
it('throws when deck not found or not a copy', async () => {
|
||||
nextResults.push({ data: null, error: new Error('Not found') })
|
||||
await expect(decksApi.getSourceUpdatePreview('d1', 'user1')).rejects.toThrow('not found or not a copy')
|
||||
})
|
||||
|
||||
it('returns source, copy and changes', async () => {
|
||||
const copy = { id: 'c1', copied_from_deck_id: 's1', title: 'Old', description: 'D', questions: [] }
|
||||
const sourceDeck = { id: 's1', title: 'New Title', published: true, version: 2, description: 'D', config: {}, questions: [{ prompt: 'Q' }] }
|
||||
nextResults.push(
|
||||
{ data: copy, error: null },
|
||||
{ data: sourceDeck, error: null },
|
||||
{ data: [{ prompt: 'Q' }], error: null }
|
||||
)
|
||||
const result = await decksApi.getSourceUpdatePreview('c1', 'user1')
|
||||
expect(result.source).toBeDefined()
|
||||
expect(result.copy).toBeDefined()
|
||||
expect(result.changes).toEqual(expect.any(Array))
|
||||
})
|
||||
})
|
||||
|
||||
describe('applySourceUpdate', () => {
|
||||
it('throws when deck not a copy', async () => {
|
||||
nextResults.push({ data: null, error: new Error('Not found') })
|
||||
await expect(decksApi.applySourceUpdate('d1', 'user1')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as profileApi from './profile.js'
|
||||
|
||||
const nextResults = []
|
||||
function createChain() {
|
||||
const getResult = () => nextResults.shift() ?? { data: null, error: null }
|
||||
const chain = {
|
||||
from: vi.fn(function () { return this }),
|
||||
select: vi.fn(function () { return this }),
|
||||
eq: vi.fn(function () { return this }),
|
||||
ilike: vi.fn(function () { return this }),
|
||||
limit: vi.fn(function () { return this }),
|
||||
single: vi.fn(() => Promise.resolve(getResult())),
|
||||
update: vi.fn(function () { return this }),
|
||||
then(resolve) { return Promise.resolve(getResult()).then(resolve) },
|
||||
catch(fn) { return Promise.resolve(getResult()).catch(fn) },
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
const mockStorageFrom = {
|
||||
getPublicUrl: vi.fn(() => ({ data: { publicUrl: 'https://example.com/avatars/path' } })),
|
||||
upload: vi.fn(() => Promise.resolve({ error: null })),
|
||||
}
|
||||
|
||||
vi.mock('../supabase.js', () => ({
|
||||
supabase: {
|
||||
from: vi.fn(() => createChain()),
|
||||
storage: { from: vi.fn(() => mockStorageFrom) },
|
||||
},
|
||||
}))
|
||||
|
||||
describe('profile API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
nextResults.length = 0
|
||||
})
|
||||
|
||||
describe('getEmailForUsername', () => {
|
||||
it('returns null for empty or whitespace username', async () => {
|
||||
expect(await profileApi.getEmailForUsername('')).toBe(null)
|
||||
expect(await profileApi.getEmailForUsername(' ')).toBe(null)
|
||||
expect(await profileApi.getEmailForUsername(null)).toBe(null)
|
||||
})
|
||||
|
||||
it('returns email when profile found', async () => {
|
||||
nextResults.push({ data: [{ email: 'user@example.com' }], error: null })
|
||||
const email = await profileApi.getEmailForUsername('johndoe')
|
||||
expect(email).toBe('user@example.com')
|
||||
})
|
||||
|
||||
it('returns null when error or no data', async () => {
|
||||
nextResults.push({ data: [], error: null })
|
||||
expect(await profileApi.getEmailForUsername('x')).toBe(null)
|
||||
nextResults.push({ data: null, error: { message: 'err' } })
|
||||
expect(await profileApi.getEmailForUsername('y')).toBe(null)
|
||||
nextResults.push({ data: [{}], error: null })
|
||||
expect(await profileApi.getEmailForUsername('z')).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProfile', () => {
|
||||
it('returns profile when found', async () => {
|
||||
const p = { id: 'u1', display_name: 'Alice', email: 'a@b.com', avatar_url: null }
|
||||
nextResults.push({ data: p, error: null })
|
||||
const result = await profileApi.getProfile('u1')
|
||||
expect(result).toEqual(p)
|
||||
})
|
||||
|
||||
it('returns null when not found (PGRST116)', async () => {
|
||||
nextResults.push({ data: null, error: { code: 'PGRST116' } })
|
||||
const result = await profileApi.getProfile('u1')
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it('throws when other error', async () => {
|
||||
nextResults.push({ data: null, error: new Error('Network error') })
|
||||
await expect(profileApi.getProfile('u1')).rejects.toThrow('Network error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateProfile', () => {
|
||||
it('updates display_name and returns data', async () => {
|
||||
const updated = { id: 'u1', display_name: 'New Name', email: 'a@b.com', avatar_url: null }
|
||||
nextResults.push({ data: updated, error: null })
|
||||
const result = await profileApi.updateProfile('u1', { display_name: 'New Name' })
|
||||
expect(result).toEqual(updated)
|
||||
})
|
||||
|
||||
it('trims display_name and allows avatar_url', async () => {
|
||||
nextResults.push({ data: { id: 'u1' }, error: null })
|
||||
await profileApi.updateProfile('u1', { display_name: ' Trim ', avatar_url: 'path/to/av.jpg' })
|
||||
expect(nextResults.length).toBe(0)
|
||||
})
|
||||
|
||||
it('throws on error', async () => {
|
||||
nextResults.push({ data: null, error: new Error('DB error') })
|
||||
await expect(profileApi.updateProfile('u1', { display_name: 'X' })).rejects.toThrow('DB error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAvatarPublicUrl', () => {
|
||||
it('returns publicUrl from storage', () => {
|
||||
const url = profileApi.getAvatarPublicUrl('user1/avatar.jpg')
|
||||
expect(url).toBe('https://example.com/avatars/path')
|
||||
expect(mockStorageFrom.getPublicUrl).toHaveBeenCalledWith('user1/avatar.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadAvatar', () => {
|
||||
it('uploads file and returns public URL', async () => {
|
||||
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' })
|
||||
const url = await profileApi.uploadAvatar('u1', file)
|
||||
expect(mockStorageFrom.upload).toHaveBeenCalledWith('u1/avatar.jpg', file, { upsert: true, contentType: 'image/jpeg' })
|
||||
expect(url).toBe('https://example.com/avatars/path')
|
||||
})
|
||||
|
||||
it('throws on upload error', async () => {
|
||||
mockStorageFrom.upload.mockResolvedValueOnce({ error: new Error('Storage full') })
|
||||
const file = new File(['x'], 'a.png', { type: 'image/png' })
|
||||
await expect(profileApi.uploadAvatar('u1', file)).rejects.toThrow('Storage full')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { DEFAULT_DECK_CONFIG } from './deckConfig.js'
|
||||
|
||||
describe('deckConfig', () => {
|
||||
it('exports DEFAULT_DECK_CONFIG with expected keys', () => {
|
||||
expect(DEFAULT_DECK_CONFIG).toMatchObject({
|
||||
requiredConsecutiveCorrect: 3,
|
||||
defaultAttemptSize: 10,
|
||||
priorityIncreaseOnIncorrect: 5,
|
||||
priorityDecreaseOnCorrect: 2,
|
||||
immediateFeedbackEnabled: true,
|
||||
includeKnownInAttempts: false,
|
||||
shuffleAnswerOrder: true,
|
||||
excludeFlaggedQuestions: false,
|
||||
timeLimitSeconds: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('has only known config keys', () => {
|
||||
const known = new Set([
|
||||
'requiredConsecutiveCorrect',
|
||||
'defaultAttemptSize',
|
||||
'priorityIncreaseOnIncorrect',
|
||||
'priorityDecreaseOnCorrect',
|
||||
'immediateFeedbackEnabled',
|
||||
'includeKnownInAttempts',
|
||||
'shuffleAnswerOrder',
|
||||
'excludeFlaggedQuestions',
|
||||
'timeLimitSeconds',
|
||||
])
|
||||
for (const key of Object.keys(DEFAULT_DECK_CONFIG)) {
|
||||
expect(known.has(key)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,130 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { get } from 'svelte/store'
|
||||
import { auth } from './auth.js'
|
||||
|
||||
const mockGetSession = vi.fn()
|
||||
const mockOnAuthStateChange = vi.fn()
|
||||
const mockSignInWithPassword = vi.fn()
|
||||
const mockSignUp = vi.fn()
|
||||
const mockSignOut = vi.fn()
|
||||
|
||||
vi.mock('../supabase.js', () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: (...args) => mockGetSession(...args),
|
||||
onAuthStateChange: (...args) => mockOnAuthStateChange(...args),
|
||||
signInWithPassword: (...args) => mockSignInWithPassword(...args),
|
||||
signUp: (...args) => mockSignUp(...args),
|
||||
signOut: (...args) => mockSignOut(...args),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const mockGetEmailForUsername = vi.fn()
|
||||
const mockUpdateProfile = vi.fn()
|
||||
vi.mock('../api/profile.js', () => ({
|
||||
getEmailForUsername: (...args) => mockGetEmailForUsername(...args),
|
||||
updateProfile: (...args) => mockUpdateProfile(...args),
|
||||
}))
|
||||
|
||||
describe('auth store', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSession.mockResolvedValue({ data: { session: null } })
|
||||
mockSignOut.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('has initial state with loading true', () => {
|
||||
expect(get(auth)).toMatchObject({ user: null, loading: true, error: null })
|
||||
})
|
||||
|
||||
it('clearError sets error to null', async () => {
|
||||
auth.clearError()
|
||||
expect(get(auth).error).toBe(null)
|
||||
})
|
||||
|
||||
it('init sets session from getSession and registers onAuthStateChange', async () => {
|
||||
const user = { id: 'u1', email: 'a@b.com' }
|
||||
mockGetSession.mockResolvedValue({ data: { session: { user } } })
|
||||
await auth.init()
|
||||
expect(get(auth)).toMatchObject({ user, loading: false, error: null })
|
||||
expect(mockOnAuthStateChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('init sets error when getSession throws', async () => {
|
||||
mockGetSession.mockRejectedValue(new Error('Network error'))
|
||||
await auth.init()
|
||||
expect(get(auth)).toMatchObject({ user: null, loading: false, error: 'Network error' })
|
||||
})
|
||||
|
||||
it('login with empty email sets error and returns false', async () => {
|
||||
const result = await auth.login('', 'pass')
|
||||
expect(result).toBe(false)
|
||||
expect(get(auth).error).toBe('Enter your email or username.')
|
||||
})
|
||||
|
||||
it('login with username resolves email and signs in', async () => {
|
||||
mockGetEmailForUsername.mockResolvedValue('user@example.com')
|
||||
mockSignInWithPassword.mockResolvedValue({ error: null })
|
||||
const result = await auth.login('username', 'pass')
|
||||
expect(mockGetEmailForUsername).toHaveBeenCalledWith('username')
|
||||
expect(mockSignInWithPassword).toHaveBeenCalledWith({
|
||||
email: 'user@example.com',
|
||||
password: 'pass',
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
expect(get(auth).error).toBe(null)
|
||||
})
|
||||
|
||||
it('login with unknown username sets error and returns false', async () => {
|
||||
mockGetEmailForUsername.mockResolvedValue(null)
|
||||
const result = await auth.login('unknown', 'pass')
|
||||
expect(result).toBe(false)
|
||||
expect(get(auth).error).toBe('No account with that username.')
|
||||
})
|
||||
|
||||
it('login with email signs in directly', async () => {
|
||||
mockSignInWithPassword.mockResolvedValue({ error: null })
|
||||
const result = await auth.login('a@b.com', 'pass')
|
||||
expect(mockGetEmailForUsername).not.toHaveBeenCalled()
|
||||
expect(mockSignInWithPassword).toHaveBeenCalledWith({ email: 'a@b.com', password: 'pass' })
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('login with signIn error sets error and returns false', async () => {
|
||||
mockSignInWithPassword.mockResolvedValue({ error: { message: 'Invalid login' } })
|
||||
const result = await auth.login('a@b.com', 'wrong')
|
||||
expect(result).toBe(false)
|
||||
expect(get(auth).error).toBe('Invalid login')
|
||||
})
|
||||
|
||||
it('register success returns success and data', async () => {
|
||||
const user = { id: 'u1' }
|
||||
const session = {}
|
||||
mockSignUp.mockResolvedValue({ data: { user, session }, error: null })
|
||||
const result = await auth.register('a@b.com', 'pass', 'Alice')
|
||||
expect(result).toEqual({ success: true, needsConfirmation: false, data: { user, session } })
|
||||
expect(get(auth).error).toBe(null)
|
||||
})
|
||||
|
||||
it('register with displayName calls updateProfile', async () => {
|
||||
const user = { id: 'u1' }
|
||||
mockSignUp.mockResolvedValue({ data: { user, session: {} }, error: null })
|
||||
mockUpdateProfile.mockResolvedValue(undefined)
|
||||
await auth.register('a@b.com', 'pass', 'Alice')
|
||||
expect(mockUpdateProfile).toHaveBeenCalledWith('u1', { display_name: 'Alice' })
|
||||
})
|
||||
|
||||
it('register with signUp error returns success false', async () => {
|
||||
mockSignUp.mockResolvedValue({ data: null, error: { message: 'Email taken' } })
|
||||
const result = await auth.register('a@b.com', 'pass', '')
|
||||
expect(result).toEqual({ success: false })
|
||||
expect(get(auth).error).toBe('Email taken')
|
||||
})
|
||||
|
||||
it('logout calls signOut and clears user', async () => {
|
||||
await auth.logout()
|
||||
expect(mockSignOut).toHaveBeenCalled()
|
||||
expect(get(auth)).toMatchObject({ user: null, loading: false, error: null })
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { get } from 'svelte/store'
|
||||
import { navContext } from './navContext.js'
|
||||
|
||||
describe('navContext', () => {
|
||||
afterEach(() => {
|
||||
navContext.set(null)
|
||||
})
|
||||
|
||||
it('is a writable store with initial value null', () => {
|
||||
expect(get(navContext)).toBe(null)
|
||||
})
|
||||
|
||||
it('updates when set is called', () => {
|
||||
navContext.set('my-decks')
|
||||
expect(get(navContext)).toBe('my-decks')
|
||||
navContext.set('community')
|
||||
expect(get(navContext)).toBe('community')
|
||||
navContext.set(null)
|
||||
expect(get(navContext)).toBe(null)
|
||||
})
|
||||
|
||||
it('updates when update is called', () => {
|
||||
navContext.set('community')
|
||||
navContext.update((v) => (v === 'community' ? 'my-decks' : v))
|
||||
expect(get(navContext)).toBe('my-decks')
|
||||
navContext.set(null)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
describe('main', () => {
|
||||
let appRoot
|
||||
|
||||
beforeEach(() => {
|
||||
appRoot = document.createElement('div')
|
||||
appRoot.id = 'app'
|
||||
document.body.innerHTML = ''
|
||||
document.body.appendChild(appRoot)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('mounts App to #app and exports the app instance', async () => {
|
||||
const main = await import('./main.js')
|
||||
expect(main.default).toBeDefined()
|
||||
expect(main.default).toHaveProperty('$destroy')
|
||||
expect(appRoot.hasChildNodes()).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import Community from './Community.svelte'
|
||||
import { auth } from '../lib/stores/auth.js'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('svelte-spa-router', () => ({ push: (...args) => mockPush(...args) }))
|
||||
|
||||
vi.mock('../lib/stores/auth.js', () => {
|
||||
const { writable } = require('svelte/store')
|
||||
const store = writable({ user: { id: 'u1' }, loading: false, error: null })
|
||||
return { auth: { subscribe: store.subscribe, set: store.set } }
|
||||
})
|
||||
|
||||
const mockFetchPublishedDecks = vi.fn()
|
||||
const mockCopyDeckToUser = vi.fn()
|
||||
vi.mock('../lib/api/decks.js', () => ({
|
||||
fetchPublishedDecks: (...args) => mockFetchPublishedDecks(...args),
|
||||
copyDeckToUser: (...args) => mockCopyDeckToUser(...args),
|
||||
getMyDeckRating: vi.fn(),
|
||||
submitDeckRating: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Community', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
auth.set({ user: { id: 'u1' }, loading: false, error: null })
|
||||
mockFetchPublishedDecks.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('shows loading then empty message when no decks', async () => {
|
||||
render(Community)
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByText(/No published decks yet/)).toBeInTheDocument())
|
||||
expect(mockFetchPublishedDecks).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows error when fetch fails', async () => {
|
||||
mockFetchPublishedDecks.mockRejectedValue(new Error('Network error'))
|
||||
render(Community)
|
||||
await waitFor(() => expect(screen.getByText('Network error')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('renders deck list and goPreview on card click', async () => {
|
||||
mockFetchPublishedDecks.mockResolvedValue([
|
||||
{
|
||||
id: 'd1',
|
||||
title: 'Cool Deck',
|
||||
description: 'A deck',
|
||||
owner_id: 'o1',
|
||||
owner_display_name: 'Alice',
|
||||
owner_email: 'a@b.com',
|
||||
question_count: 5,
|
||||
average_rating: 4.5,
|
||||
rating_count: 2,
|
||||
user_has_this: false,
|
||||
},
|
||||
])
|
||||
render(Community)
|
||||
await waitFor(() => expect(screen.getByText('Cool Deck')).toBeInTheDocument())
|
||||
await fireEvent.click(screen.getByRole('button', { name: /Cool Deck/ }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/decks/d1/preview')
|
||||
})
|
||||
|
||||
it('handleUse without userId shows sign in error', async () => {
|
||||
auth.set({ user: null, loading: false, error: null })
|
||||
mockFetchPublishedDecks.mockResolvedValue([
|
||||
{ id: 'd1', title: 'Deck', owner_id: 'o1', owner_display_name: 'A', question_count: 1, user_has_this: false },
|
||||
])
|
||||
render(Community)
|
||||
await waitFor(() => expect(screen.getByText('Deck')).toBeInTheDocument())
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(screen.getByText(/Sign in to add this deck/)).toBeInTheDocument()
|
||||
expect(mockCopyDeckToUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleUse with userId calls copyDeckToUser and reloads', async () => {
|
||||
mockFetchPublishedDecks
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'd1', title: 'Deck', owner_id: 'o1', owner_display_name: 'A', question_count: 1, user_has_this: false },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'd1', title: 'Deck', owner_id: 'o1', owner_display_name: 'A', question_count: 1, user_has_this: true },
|
||||
])
|
||||
mockCopyDeckToUser.mockResolvedValue(undefined)
|
||||
render(Community)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument())
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
await waitFor(() => expect(mockCopyDeckToUser).toHaveBeenCalledWith('d1', 'u1'))
|
||||
await waitFor(() => expect(screen.getByText('In My decks')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import CommunityUser from './CommunityUser.svelte'
|
||||
import { auth } from '../lib/stores/auth.js'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('svelte-spa-router', () => ({
|
||||
push: (...args) => mockPush(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/stores/auth.js', () => {
|
||||
const { writable } = require('svelte/store')
|
||||
const store = writable({ user: { id: 'u1' }, loading: false, error: null })
|
||||
return { auth: { subscribe: store.subscribe, set: store.set } }
|
||||
})
|
||||
|
||||
const mockFetchPublishedDecksByOwner = vi.fn()
|
||||
const mockCopyDeckToUser = vi.fn()
|
||||
const mockGetMyDeckRating = vi.fn()
|
||||
const mockSubmitDeckRating = vi.fn()
|
||||
vi.mock('../lib/api/decks.js', () => ({
|
||||
fetchPublishedDecksByOwner: (...args) => mockFetchPublishedDecksByOwner(...args),
|
||||
copyDeckToUser: (...args) => mockCopyDeckToUser(...args),
|
||||
getMyDeckRating: (...args) => mockGetMyDeckRating(...args),
|
||||
submitDeckRating: (...args) => mockSubmitDeckRating(...args),
|
||||
}))
|
||||
|
||||
describe('CommunityUser', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
auth.set({ user: { id: 'u1' }, loading: false, error: null })
|
||||
mockFetchPublishedDecksByOwner.mockResolvedValue({
|
||||
decks: [],
|
||||
owner_display_name: 'Test User',
|
||||
owner_email: 'test@example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('loads and shows owner name when no decks', async () => {
|
||||
render(CommunityUser, { props: { params: { id: 'owner1' } } })
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByText(/Decks by Test User/)).toBeInTheDocument())
|
||||
expect(mockFetchPublishedDecksByOwner).toHaveBeenCalledWith('owner1', 'u1')
|
||||
})
|
||||
|
||||
it('shows error when fetch fails', async () => {
|
||||
mockFetchPublishedDecksByOwner.mockRejectedValue(new Error('Not found'))
|
||||
render(CommunityUser, { props: { params: { id: 'owner1' } } })
|
||||
await waitFor(() => expect(screen.getByText('Not found')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('goCommunity button calls push to /community', async () => {
|
||||
render(CommunityUser, { props: { params: { id: 'owner1' } } })
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Community/ })).toBeInTheDocument())
|
||||
await fireEvent.click(screen.getByRole('button', { name: /Community/ }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/community')
|
||||
})
|
||||
|
||||
it('renders deck list when decks returned', async () => {
|
||||
mockFetchPublishedDecksByOwner.mockResolvedValue({
|
||||
decks: [
|
||||
{ id: 'd1', title: 'Deck One', owner_id: 'owner1', question_count: 3, user_has_this: false },
|
||||
],
|
||||
owner_display_name: 'Alice',
|
||||
owner_email: 'a@b.com',
|
||||
})
|
||||
render(CommunityUser, { props: { params: { id: 'owner1' } } })
|
||||
await waitFor(() => expect(screen.getByText('Deck One')).toBeInTheDocument())
|
||||
expect(screen.getByText(/Decks by Alice/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import CreateDeck from './CreateDeck.svelte'
|
||||
import { auth } from '../lib/stores/auth.js'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('svelte-spa-router', () => ({
|
||||
push: (...args) => mockPush(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/stores/auth.js', () => {
|
||||
const { writable } = require('svelte/store')
|
||||
const store = writable({ user: { id: 'u1' }, loading: false, error: null })
|
||||
return { auth: { subscribe: store.subscribe, set: store.set } }
|
||||
})
|
||||
|
||||
const mockCreateDeck = vi.fn()
|
||||
vi.mock('../lib/api/decks.js', () => ({
|
||||
createDeck: (...args) => mockCreateDeck(...args),
|
||||
}))
|
||||
|
||||
describe('CreateDeck', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
auth.set({ user: { id: 'u1' }, loading: false, error: null })
|
||||
mockCreateDeck.mockResolvedValue('new-deck-id')
|
||||
})
|
||||
|
||||
it('shows form when user is logged in', async () => {
|
||||
render(CreateDeck)
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Deck title')).toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: 'Save deck' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows sign in message when user is null', () => {
|
||||
auth.set({ user: null, loading: false, error: null })
|
||||
render(CreateDeck)
|
||||
expect(screen.getByText('Sign in to create a deck.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Cancel calls push to home', async () => {
|
||||
render(CreateDeck)
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Deck title')).toBeInTheDocument())
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/')
|
||||
})
|
||||
|
||||
it('loadFromJson sets error for invalid JSON', async () => {
|
||||
render(CreateDeck)
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Deck title')).toBeInTheDocument())
|
||||
const textarea = screen.getByPlaceholderText(/Paste deck JSON/)
|
||||
await fireEvent.input(textarea, { target: { value: 'not json' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Load from JSON' }))
|
||||
expect(screen.getByText(/Invalid JSON/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loadFromJson sets error when title missing', async () => {
|
||||
render(CreateDeck)
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Deck title')).toBeInTheDocument())
|
||||
const textarea = screen.getByPlaceholderText(/Paste deck JSON/)
|
||||
await fireEvent.input(textarea, { target: { value: '{"description":"x","questions":[{"prompt":"Q","answers":["A"],"correctAnswerIndices":[0]}]}' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Load from JSON' }))
|
||||
expect(screen.getByText(/must include a "title"/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loadFromJson populates title and questions from valid JSON', async () => {
|
||||
render(CreateDeck)
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Deck title')).toBeInTheDocument())
|
||||
const validJson = JSON.stringify({
|
||||
title: 'Imported Deck',
|
||||
description: 'Desc',
|
||||
questions: [{ prompt: 'Question?', answers: ['A', 'B'], correctAnswerIndices: [0] }],
|
||||
})
|
||||
const textarea = screen.getByPlaceholderText(/Paste deck JSON/)
|
||||
await fireEvent.input(textarea, { target: { value: validJson } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Load from JSON' }))
|
||||
expect(screen.getByDisplayValue('Imported Deck')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Question?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('save with empty title shows error', async () => {
|
||||
render(CreateDeck)
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Deck title')).toBeInTheDocument())
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Save deck' }))
|
||||
expect(screen.getByText('Title is required')).toBeInTheDocument()
|
||||
expect(mockCreateDeck).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('save with title and question calls createDeck and pushes home', async () => {
|
||||
render(CreateDeck)
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Deck title')).toBeInTheDocument())
|
||||
await fireEvent.input(screen.getByPlaceholderText('Deck title'), { target: { value: 'My Deck' } })
|
||||
await fireEvent.input(screen.getByPlaceholderText('Prompt'), { target: { value: 'First question?' } })
|
||||
await fireEvent.input(screen.getByPlaceholderText('Answer 1'), { target: { value: 'Yes' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Save deck' }))
|
||||
await waitFor(() => expect(mockCreateDeck).toHaveBeenCalled())
|
||||
expect(mockCreateDeck).toHaveBeenCalledWith('u1', expect.objectContaining({ title: 'My Deck' }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/')
|
||||
})
|
||||
|
||||
it('Add question button adds a question editor', async () => {
|
||||
render(CreateDeck)
|
||||
await waitFor(() => expect(screen.getByText('Question 1')).toBeInTheDocument())
|
||||
await fireEvent.click(screen.getByRole('button', { name: '+ Add question' }))
|
||||
expect(screen.getByText('Question 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import DeckPreview from './DeckPreview.svelte'
|
||||
import { auth } from '../lib/stores/auth.js'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('svelte-spa-router', () => ({
|
||||
push: (...args) => mockPush(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/stores/auth.js', () => {
|
||||
const { writable } = require('svelte/store')
|
||||
const store = writable({ user: { id: 'u1' }, loading: false, error: null })
|
||||
return { auth: { subscribe: store.subscribe, set: store.set } }
|
||||
})
|
||||
|
||||
const mockFetchDeckWithQuestions = vi.fn()
|
||||
const mockUserHasDeck = vi.fn()
|
||||
const mockCopyDeckToUser = vi.fn()
|
||||
vi.mock('../lib/api/decks.js', () => ({
|
||||
fetchDeckWithQuestions: (...args) => mockFetchDeckWithQuestions(...args),
|
||||
userHasDeck: (...args) => mockUserHasDeck(...args),
|
||||
copyDeckToUser: (...args) => mockCopyDeckToUser(...args),
|
||||
}))
|
||||
|
||||
describe('DeckPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
auth.set({ user: { id: 'u1' }, loading: false, error: null })
|
||||
mockFetchDeckWithQuestions.mockResolvedValue({
|
||||
id: 'd1',
|
||||
title: 'Preview Deck',
|
||||
description: 'Desc',
|
||||
published: true,
|
||||
owner_id: 'o1',
|
||||
copied_from_deck_id: null,
|
||||
questions: [{ prompt: 'Q?', answers: ['A'], correct_answer_indices: [0] }],
|
||||
})
|
||||
mockUserHasDeck.mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it('loads and shows deck title', async () => {
|
||||
render(DeckPreview, { props: { params: { id: 'd1' } } })
|
||||
expect(screen.getByText('Loading deck…')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByText('Preview Deck')).toBeInTheDocument())
|
||||
expect(mockFetchDeckWithQuestions).toHaveBeenCalledWith('d1')
|
||||
})
|
||||
|
||||
it('shows error when deck not available', async () => {
|
||||
mockFetchDeckWithQuestions.mockResolvedValue({
|
||||
id: 'd1',
|
||||
title: 'Private',
|
||||
published: false,
|
||||
owner_id: 'o1',
|
||||
questions: [],
|
||||
})
|
||||
render(DeckPreview, { props: { params: { id: 'd1' } } })
|
||||
await waitFor(() => expect(screen.getByText('This deck is not available.')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('goBack pushes to community for published deck', async () => {
|
||||
render(DeckPreview, { props: { params: { id: 'd1' } } })
|
||||
await waitFor(() => expect(screen.getByText('Preview Deck')).toBeInTheDocument())
|
||||
await fireEvent.click(screen.getByRole('button', { name: /Community/ }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/community')
|
||||
})
|
||||
|
||||
it('addToMyDecks calls copyDeckToUser', async () => {
|
||||
render(DeckPreview, { props: { params: { id: 'd1' } } })
|
||||
await waitFor(() => expect(screen.getByText('Preview Deck')).toBeInTheDocument())
|
||||
const addBtn = screen.getByRole('button', { name: /Add to My decks/i })
|
||||
mockCopyDeckToUser.mockResolvedValue(undefined)
|
||||
await fireEvent.click(addBtn)
|
||||
await waitFor(() => expect(mockCopyDeckToUser).toHaveBeenCalledWith('d1', 'u1'))
|
||||
})
|
||||
|
||||
it('addToMyDecks without userId shows sign in error', async () => {
|
||||
auth.set({ user: null, loading: false, error: null })
|
||||
render(DeckPreview, { props: { params: { id: 'd1' } } })
|
||||
await waitFor(() => expect(screen.getByText('Preview Deck')).toBeInTheDocument())
|
||||
const addBtn = screen.getByRole('button', { name: /Add to My decks/i })
|
||||
await fireEvent.click(addBtn)
|
||||
expect(screen.getByText(/Sign in to add this deck/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import DeckReviews from './DeckReviews.svelte'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('svelte-spa-router', () => ({
|
||||
push: (...args) => mockPush(...args),
|
||||
}))
|
||||
|
||||
const mockFetchDeckWithQuestions = vi.fn()
|
||||
const mockGetDeckReviews = vi.fn()
|
||||
vi.mock('../lib/api/decks.js', () => ({
|
||||
fetchDeckWithQuestions: (...args) => mockFetchDeckWithQuestions(...args),
|
||||
getDeckReviews: (...args) => mockGetDeckReviews(...args),
|
||||
}))
|
||||
|
||||
describe('DeckReviews', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchDeckWithQuestions.mockResolvedValue({
|
||||
id: 'd1',
|
||||
title: 'Test Deck',
|
||||
published: true,
|
||||
questions: [],
|
||||
})
|
||||
mockGetDeckReviews.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('shows loading then deck title and no reviews', async () => {
|
||||
render(DeckReviews, { props: { params: { id: 'd1' } } })
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByText('Test Deck')).toBeInTheDocument())
|
||||
expect(screen.getByText('No reviews yet.')).toBeInTheDocument()
|
||||
expect(mockFetchDeckWithQuestions).toHaveBeenCalledWith('d1')
|
||||
expect(mockGetDeckReviews).toHaveBeenCalledWith('d1')
|
||||
})
|
||||
|
||||
it('shows error when deck fetch fails', async () => {
|
||||
mockFetchDeckWithQuestions.mockRejectedValue(new Error('Network error'))
|
||||
render(DeckReviews, { props: { params: { id: 'd1' } } })
|
||||
await waitFor(() => expect(screen.getByText('Network error')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('shows error when deck is not published', async () => {
|
||||
mockFetchDeckWithQuestions.mockResolvedValue({
|
||||
id: 'd1',
|
||||
title: 'Private',
|
||||
published: false,
|
||||
questions: [],
|
||||
})
|
||||
render(DeckReviews, { props: { params: { id: 'd1' } } })
|
||||
await waitFor(() => expect(screen.getByText('This deck is not available.')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('renders reviews list and goBack calls push', async () => {
|
||||
mockGetDeckReviews.mockResolvedValue([
|
||||
{
|
||||
user_id: 'u1',
|
||||
rating: 5,
|
||||
comment: 'Great!',
|
||||
display_name: 'Alice',
|
||||
email: 'a@b.com',
|
||||
avatar_url: null,
|
||||
},
|
||||
])
|
||||
render(DeckReviews, { props: { params: { id: 'd1' } } })
|
||||
await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument())
|
||||
expect(screen.getByText('Great!')).toBeInTheDocument()
|
||||
await fireEvent.click(screen.getByRole('button', { name: /Back to deck/i }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/decks/d1/preview')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import EditDeck from './EditDeck.svelte'
|
||||
import { auth } from '../lib/stores/auth.js'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('svelte-spa-router', () => ({ push: (...args) => mockPush(...args) }))
|
||||
|
||||
vi.mock('../lib/stores/auth.js', () => {
|
||||
const { writable } = require('svelte/store')
|
||||
const store = writable({ user: { id: 'u1' }, loading: false, error: null })
|
||||
return { auth: { subscribe: store.subscribe, set: store.set } }
|
||||
})
|
||||
|
||||
const mockFetchDeckWithQuestions = vi.fn()
|
||||
const mockUpdateDeck = vi.fn()
|
||||
vi.mock('../lib/api/decks.js', () => ({
|
||||
fetchDeckWithQuestions: (...args) => mockFetchDeckWithQuestions(...args),
|
||||
updateDeck: (...args) => mockUpdateDeck(...args),
|
||||
togglePublished: vi.fn(),
|
||||
deleteDeck: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('EditDeck', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
auth.set({ user: { id: 'u1' }, loading: false, error: null })
|
||||
mockFetchDeckWithQuestions.mockResolvedValue({
|
||||
id: 'd1',
|
||||
title: 'My Deck',
|
||||
description: 'Desc',
|
||||
owner_id: 'u1',
|
||||
published: false,
|
||||
copied_from_deck_id: null,
|
||||
config: {},
|
||||
questions: [{ prompt: 'Q?', explanation: '', answers: ['A'], correct_answer_indices: [0] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading then form when deck owned by user', async () => {
|
||||
render(EditDeck, { props: { params: { id: 'd1' } } })
|
||||
expect(screen.getByText(/Loading deck/)).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByDisplayValue('My Deck')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('redirects when deck owner is not current user', async () => {
|
||||
mockFetchDeckWithQuestions.mockResolvedValue({
|
||||
id: 'd1',
|
||||
title: 'Other',
|
||||
owner_id: 'other-user',
|
||||
questions: [],
|
||||
})
|
||||
render(EditDeck, { props: { params: { id: 'd1' } } })
|
||||
await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/'))
|
||||
})
|
||||
|
||||
it('cancel calls push to home', async () => {
|
||||
render(EditDeck, { props: { params: { id: 'd1' } } })
|
||||
await waitFor(() => expect(screen.getByDisplayValue('My Deck')).toBeInTheDocument())
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/')
|
||||
})
|
||||
|
||||
it('save calls updateDeck and pushes home', async () => {
|
||||
mockUpdateDeck.mockResolvedValue(undefined)
|
||||
render(EditDeck, { props: { params: { id: 'd1' } } })
|
||||
await waitFor(() => expect(screen.getByDisplayValue('My Deck')).toBeInTheDocument())
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(mockUpdateDeck).toHaveBeenCalledWith('d1', expect.any(Object)))
|
||||
expect(mockPush).toHaveBeenCalledWith('/')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import MyDecks from './MyDecks.svelte'
|
||||
import { auth } from '../lib/stores/auth.js'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('svelte-spa-router', () => ({ push: (...args) => mockPush(...args) }))
|
||||
|
||||
vi.mock('../lib/stores/auth.js', () => {
|
||||
const { writable } = require('svelte/store')
|
||||
const store = writable({ user: { id: 'u1' }, loading: false, error: null })
|
||||
return { auth: { subscribe: store.subscribe, set: store.set } }
|
||||
})
|
||||
|
||||
const mockFetchMyDecks = vi.fn()
|
||||
const mockTogglePublished = vi.fn()
|
||||
const mockGetMyDeckRating = vi.fn()
|
||||
const mockSubmitDeckRating = vi.fn()
|
||||
const mockGetSourceUpdatePreview = vi.fn()
|
||||
const mockApplySourceUpdate = vi.fn()
|
||||
const mockDeleteDeck = vi.fn()
|
||||
vi.mock('../lib/api/decks.js', () => ({
|
||||
fetchMyDecks: (...args) => mockFetchMyDecks(...args),
|
||||
togglePublished: (...args) => mockTogglePublished(...args),
|
||||
getMyDeckRating: (...args) => mockGetMyDeckRating(...args),
|
||||
submitDeckRating: (...args) => mockSubmitDeckRating(...args),
|
||||
getSourceUpdatePreview: (...args) => mockGetSourceUpdatePreview(...args),
|
||||
applySourceUpdate: (...args) => mockApplySourceUpdate(...args),
|
||||
deleteDeck: (...args) => mockDeleteDeck(...args),
|
||||
}))
|
||||
|
||||
describe('MyDecks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
auth.set({ user: { id: 'u1' }, loading: false, error: null })
|
||||
mockFetchMyDecks.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('shows sign in message when no user', () => {
|
||||
auth.set({ user: null, loading: false, error: null })
|
||||
render(MyDecks)
|
||||
expect(screen.getByText(/Sign in to create and manage your decks/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loading then empty message when no decks', async () => {
|
||||
render(MyDecks)
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByText(/No decks yet/)).toBeInTheDocument())
|
||||
expect(mockFetchMyDecks).toHaveBeenCalledWith('u1')
|
||||
})
|
||||
|
||||
it('shows error when fetch fails', async () => {
|
||||
mockFetchMyDecks.mockRejectedValue(new Error('Failed to load'))
|
||||
render(MyDecks)
|
||||
await waitFor(() => expect(screen.getByText('Failed to load')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('renders deck list and goEdit navigates to edit', async () => {
|
||||
mockFetchMyDecks.mockResolvedValue([
|
||||
{ id: 'd1', title: 'Deck One', description: '', question_count: 2, copied_from_deck_id: null },
|
||||
])
|
||||
render(MyDecks)
|
||||
await waitFor(() => expect(screen.getByText('Deck One')).toBeInTheDocument())
|
||||
await fireEvent.click(screen.getByTitle('Edit deck'))
|
||||
expect(mockPush).toHaveBeenCalledWith('/decks/d1/edit')
|
||||
})
|
||||
|
||||
it('clicking card goes to preview', async () => {
|
||||
mockFetchMyDecks.mockResolvedValue([
|
||||
{ id: 'd1', title: 'Deck One', description: '', question_count: 2, copied_from_deck_id: null },
|
||||
])
|
||||
render(MyDecks)
|
||||
await waitFor(() => expect(screen.getByText('Deck One')).toBeInTheDocument())
|
||||
await fireEvent.click(screen.getByRole('button', { name: /Deck One/ }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/decks/d1/preview')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import Settings from './Settings.svelte'
|
||||
import { auth } from '../lib/stores/auth.js'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('svelte-spa-router', () => ({
|
||||
push: (...args) => mockPush(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/stores/auth.js', () => {
|
||||
const { writable } = require('svelte/store')
|
||||
const store = writable({ user: { id: 'u1' }, loading: false, error: null })
|
||||
return { auth: { subscribe: store.subscribe, set: store.set } }
|
||||
})
|
||||
|
||||
const mockGetProfile = vi.fn()
|
||||
const mockUpdateProfile = vi.fn()
|
||||
const mockUploadAvatar = vi.fn()
|
||||
vi.mock('../lib/api/profile.js', () => ({
|
||||
getProfile: (...args) => mockGetProfile(...args),
|
||||
updateProfile: (...args) => mockUpdateProfile(...args),
|
||||
uploadAvatar: (...args) => mockUploadAvatar(...args),
|
||||
}))
|
||||
|
||||
describe('Settings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
auth.set({ user: { id: 'u1' }, loading: false, error: null })
|
||||
mockGetProfile.mockResolvedValue({
|
||||
id: 'u1',
|
||||
display_name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
avatar_url: null,
|
||||
})
|
||||
mockUpdateProfile.mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('loads profile and shows display name', async () => {
|
||||
render(Settings)
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Test User')).toBeInTheDocument())
|
||||
expect(mockGetProfile).toHaveBeenCalledWith('u1')
|
||||
})
|
||||
|
||||
it('Cancel button calls push to home', async () => {
|
||||
render(Settings)
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Test User')).toBeInTheDocument())
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/')
|
||||
})
|
||||
|
||||
it('shows error for non-image file', async () => {
|
||||
render(Settings)
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Test User')).toBeInTheDocument())
|
||||
const input = document.querySelector('input[type="file"]')
|
||||
expect(input).toBeTruthy()
|
||||
const file = new File(['x'], 'doc.pdf', { type: 'application/pdf' })
|
||||
await fireEvent.change(input, { target: { files: [file] } })
|
||||
expect(screen.getByText(/Please choose an image file/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('save updates profile and shows success', async () => {
|
||||
mockGetProfile
|
||||
.mockResolvedValueOnce({ id: 'u1', display_name: 'Test User', email: 't@t.com', avatar_url: null })
|
||||
.mockResolvedValueOnce({ id: 'u1', display_name: 'New Name', email: 't@t.com', avatar_url: null })
|
||||
render(Settings)
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Test User')).toBeInTheDocument())
|
||||
const nameInput = screen.getByDisplayValue('Test User')
|
||||
await fireEvent.input(nameInput, { target: { value: 'New Name' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateProfile).toHaveBeenCalledWith('u1', { display_name: 'New Name', avatar_url: null })
|
||||
)
|
||||
await waitFor(() => expect(screen.getByText('Settings saved.')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
Loading…
Reference in new issue