feat: add PendingRewardDialog, RewardConfirmDialog, and TaskConfirmDialog components
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 25s

- Implemented PendingRewardDialog for handling pending reward requests.
- Created RewardConfirmDialog for confirming reward redemption.
- Developed TaskConfirmDialog for task confirmation with child name display.

test: add unit tests for ChildView and ParentView components

- Added comprehensive tests for ChildView including task triggering and SSE event handling.
- Implemented tests for ParentView focusing on override modal and SSE event management.

test: add ScrollingList component tests

- Created tests for ScrollingList to verify item fetching, loading states, and custom item classes.
- Included tests for two-step click interactions and edit button display logic.
- Moved toward hashed passwords.
This commit is contained in:
2026-02-10 20:21:05 -05:00
parent 3dee8b80a2
commit 401c21ad82
45 changed files with 4353 additions and 441 deletions

View File

@@ -0,0 +1,382 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import ScrollingList from '../ScrollingList.vue'
// Mock image cache
vi.mock('@/common/imageCache', () => ({
getCachedImageUrl: vi.fn((id: string) => Promise.resolve(`/cached/${id}.png`)),
revokeAllImageUrls: vi.fn(),
}))
global.fetch = vi.fn()
describe('ScrollingList', () => {
let wrapper: VueWrapper<any>
const defaultProps = {
title: 'Test List',
fetchBaseUrl: '/api/test',
ids: ['item-1', 'item-2'],
itemKey: 'items',
}
const mockItems = [
{ id: 'item-1', name: 'Item One', points: 10, image_id: 'img1' },
{ id: 'item-2', name: 'Item Two', points: 20, image_id: 'img2' },
]
const mockItemsWithOverride = [
{ id: 'item-1', name: 'Item One', points: 10, image_id: 'img1', custom_value: 15 },
{ id: 'item-2', name: 'Item Two', points: 20, image_id: 'img2', custom_value: null },
]
beforeEach(() => {
vi.clearAllMocks()
// Mock fetch responses
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: mockItems }),
})
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('Component Mounting', () => {
it('renders with title', async () => {
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
expect(wrapper.find('h3').text()).toBe('Test List')
})
it('fetches items on mount', async () => {
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(global.fetch).toHaveBeenCalledWith('/api/test?ids=item-1,item-2')
})
it('displays loading state initially', () => {
wrapper = mount(ScrollingList, { props: defaultProps })
expect(wrapper.find('.loading').exists()).toBe(true)
})
it('displays items after loading', async () => {
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.find('.loading').exists()).toBe(false)
expect(wrapper.findAll('.item-card').length).toBe(2)
})
it('displays empty message when no items', async () => {
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [] }),
})
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.find('.empty').text()).toContain('No Test List')
})
it('displays error message on fetch failure', async () => {
;(global.fetch as any).mockRejectedValue(new Error('Network error'))
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.find('.error').exists()).toBe(true)
})
})
describe('Override Badge Display', () => {
it('shows override badge when custom_value exists and isParentAuthenticated is true', async () => {
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: mockItemsWithOverride }),
})
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: true } })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const badges = wrapper.findAll('.override-badge')
expect(badges.length).toBe(1) // Only item-1 has custom_value
expect(badges[0].text()).toBe('⭐')
})
it('does not show badge when custom_value is null', async () => {
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: mockItems }),
})
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: true } })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.findAll('.override-badge').length).toBe(0)
})
it('does not show badge when custom_value is undefined', async () => {
const itemsWithUndefined = [{ id: 'item-1', name: 'Item One', points: 10 }]
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: itemsWithUndefined }),
})
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: true } })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.findAll('.override-badge').length).toBe(0)
})
it('does not show badge in child mode even when custom_value exists', async () => {
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: mockItemsWithOverride }),
})
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: false } })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.findAll('.override-badge').length).toBe(0)
})
it('does not show badge when isParentAuthenticated is undefined', async () => {
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: mockItemsWithOverride }),
})
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.findAll('.override-badge').length).toBe(0)
})
})
describe('Two-Step Click Interaction', () => {
beforeEach(async () => {
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
enableEdit: false, // No edit button for basic click test
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('emits item-ready on first click', async () => {
const cards = wrapper.findAll('.item-card')
await cards[0].trigger('click')
expect(wrapper.emitted('item-ready')).toBeTruthy()
expect(wrapper.emitted('item-ready')![0]).toEqual(['item-1'])
})
it('emits trigger-item on second click of same item', async () => {
const cards = wrapper.findAll('.item-card')
// First click - select item
await cards[0].trigger('click')
await nextTick()
// Update prop to simulate parent setting readyItemId
await wrapper.setProps({ readyItemId: 'item-1' })
await nextTick()
// Second click - trigger item
await cards[0].trigger('click')
expect(wrapper.emitted('trigger-item')).toBeTruthy()
expect(wrapper.emitted('trigger-item')![0][0].id).toBe('item-1')
})
it('resets ready state after triggering', async () => {
const cards = wrapper.findAll('.item-card')
// First click
await cards[0].trigger('click')
await wrapper.setProps({ readyItemId: 'item-1' })
// Second click
await cards[0].trigger('click')
const itemReadyEvents = wrapper.emitted('item-ready')
expect(itemReadyEvents![itemReadyEvents!.length - 1]).toEqual([''])
})
})
describe('Edit Button Display', () => {
it('shows edit button only for readyItemId when enableEdit is true', async () => {
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
enableEdit: true,
readyItemId: 'item-1',
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const editButtons = wrapper.findAll('.edit-button')
expect(editButtons.length).toBe(1)
})
it('does not show edit button when enableEdit is false', async () => {
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
enableEdit: false,
readyItemId: 'item-1',
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.findAll('.edit-button').length).toBe(0)
})
it('does not show edit button when readyItemId is null', async () => {
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
enableEdit: true,
readyItemId: null,
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.findAll('.edit-button').length).toBe(0)
})
it('emits edit-item when edit button is clicked', async () => {
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
enableEdit: true,
readyItemId: 'item-1',
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const editButton = wrapper.find('.edit-button')
await editButton.trigger('click')
expect(wrapper.emitted('edit-item')).toBeTruthy()
expect(wrapper.emitted('edit-item')![0][0].id).toBe('item-1')
})
it('resets ready state after edit button click', async () => {
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
enableEdit: true,
readyItemId: 'item-1',
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const editButton = wrapper.find('.edit-button')
await editButton.trigger('click')
const itemReadyEvents = wrapper.emitted('item-ready')
expect(itemReadyEvents![itemReadyEvents!.length - 1]).toEqual([''])
})
})
describe('Filter Function', () => {
it('filters items using provided filterFn', async () => {
const filterFn = (item: any) => item.points > 15
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
filterFn,
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.findAll('.item-card').length).toBe(1) // Only item-2 with 20 points
})
})
describe('Refresh Method', () => {
it('exposes refresh method', async () => {
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
expect(wrapper.vm.refresh).toBeDefined()
expect(typeof wrapper.vm.refresh).toBe('function')
})
it('refetches items when refresh is called', async () => {
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
vi.clearAllMocks()
await wrapper.vm.refresh()
expect(global.fetch).toHaveBeenCalledWith('/api/test?ids=item-1,item-2')
})
})
describe('Image Loading', () => {
it('loads images for items with image_id', async () => {
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const { getCachedImageUrl } = await import('@/common/imageCache')
expect(getCachedImageUrl).toHaveBeenCalledWith('img1')
expect(getCachedImageUrl).toHaveBeenCalledWith('img2')
})
})
describe('Custom Item Classes', () => {
it('applies custom classes from getItemClass prop', async () => {
const getItemClass = (item: any) => ({
good: item.points > 15,
bad: item.points <= 15,
})
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
getItemClass,
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const cards = wrapper.findAll('.item-card')
expect(cards[0].classes()).toContain('bad') // item-1 with 10 points
expect(cards[1].classes()).toContain('good') // item-2 with 20 points
})
})
})