Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:47:01 +08:00
commit 192f93f4d5
12 changed files with 1396 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
/**
* Example component test demonstrating RTL best practices
* with accessibility testing via axe-core
*/
// Example component
interface EntityCardProps {
entity: {
id: string
name: string
type: string
description?: string
}
onEdit?: (id: string) => void
onDelete?: (id: string) => void
}
function EntityCard({ entity, onEdit, onDelete }: EntityCardProps) {
return (
<article aria-label={`Entity: ${entity.name}`}>
<header>
<h2>{entity.name}</h2>
<span className="badge">{entity.type}</span>
</header>
{entity.description && <p>{entity.description}</p>}
<footer>
{onEdit && (
<button onClick={() => onEdit(entity.id)} aria-label={`Edit ${entity.name}`}>
Edit
</button>
)}
{onDelete && (
<button onClick={() => onDelete(entity.id)} aria-label={`Delete ${entity.name}`}>
Delete
</button>
)}
</footer>
</article>
)
}
describe('EntityCard', () => {
const mockEntity = {
id: '1',
name: 'Test Character',
type: 'character',
description: 'A brave adventurer'
}
it('renders entity information', () => {
render(<EntityCard entity={mockEntity} />)
expect(screen.getByRole('heading', { name: 'Test Character' })).toBeInTheDocument()
expect(screen.getByText('character')).toBeInTheDocument()
expect(screen.getByText('A brave adventurer')).toBeInTheDocument()
})
it('does not render description when not provided', () => {
const entityWithoutDesc = { ...mockEntity, description: undefined }
render(<EntityCard entity={entityWithoutDesc} />)
expect(screen.queryByText('A brave adventurer')).not.toBeInTheDocument()
})
it('calls onEdit when edit button is clicked', async () => {
const user = userEvent.setup()
const onEdit = vi.fn()
render(<EntityCard entity={mockEntity} onEdit={onEdit} />)
await user.click(screen.getByRole('button', { name: /edit test character/i }))
expect(onEdit).toHaveBeenCalledTimes(1)
expect(onEdit).toHaveBeenCalledWith('1')
})
it('calls onDelete when delete button is clicked', async () => {
const user = userEvent.setup()
const onDelete = vi.fn()
render(<EntityCard entity={mockEntity} onDelete={onDelete} />)
await user.click(screen.getByRole('button', { name: /delete test character/i }))
expect(onDelete).toHaveBeenCalledTimes(1)
expect(onDelete).toHaveBeenCalledWith('1')
})
it('does not render action buttons when handlers not provided', () => {
render(<EntityCard entity={mockEntity} />)
expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument()
})
it('has accessible structure', () => {
render(<EntityCard entity={mockEntity} onEdit={vi.fn()} onDelete={vi.fn()} />)
const article = screen.getByRole('article', { name: /entity: test character/i })
expect(article).toBeInTheDocument()
// Check heading hierarchy
const heading = within(article).getByRole('heading', { level: 2 })
expect(heading).toHaveTextContent('Test Character')
// Check buttons have accessible names
const editButton = within(article).getByRole('button', { name: /edit test character/i })
const deleteButton = within(article).getByRole('button', { name: /delete test character/i })
expect(editButton).toBeInTheDocument()
expect(deleteButton).toBeInTheDocument()
})
it('has no accessibility violations', async () => {
const { container } = render(
<EntityCard entity={mockEntity} onEdit={vi.fn()} onDelete={vi.fn()} />
)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
})

View File

@@ -0,0 +1,196 @@
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
/**
* Example E2E test demonstrating Playwright best practices
* with accessibility testing via axe-core
*/
test.describe('Entity Management', () => {
test.beforeEach(async ({ page }) => {
// Navigate to entities page
await page.goto('/entities')
})
test('displays entity list', async ({ page }) => {
// Wait for content to load
await page.waitForSelector('[role="list"]')
// Verify entities are displayed
const entities = page.getByRole('listitem')
await expect(entities).not.toHaveCount(0)
// Verify entity cards have proper structure
const firstEntity = entities.first()
await expect(firstEntity.getByRole('heading')).toBeVisible()
})
test('creates new entity', async ({ page }) => {
// Click create button
await page.getByRole('button', { name: /create entity/i }).click()
// Verify form is displayed
await expect(page.getByRole('heading', { name: /new entity/i })).toBeVisible()
// Fill in form
await page.getByLabel(/name/i).fill('Mysterious Stranger')
await page.getByLabel(/type/i).selectOption('character')
await page.getByLabel(/description/i).fill('A traveler from distant lands')
// Submit form
await page.getByRole('button', { name: /save|create/i }).click()
// Verify success message or redirect
await expect(
page.getByText(/entity created|success/i)
).toBeVisible({ timeout: 5000 })
// Verify new entity appears in list
await page.goto('/entities')
await expect(page.getByText('Mysterious Stranger')).toBeVisible()
})
test('edits existing entity', async ({ page }) => {
// Find and click edit button for first entity
const firstEntity = page.getByRole('listitem').first()
const entityName = await firstEntity.getByRole('heading').textContent()
await firstEntity.getByRole('button', { name: /edit/i }).click()
// Update name
const nameInput = page.getByLabel(/name/i)
await nameInput.clear()
await nameInput.fill(`${entityName} (Updated)`)
// Save changes
await page.getByRole('button', { name: /save|update/i }).click()
// Verify update
await expect(page.getByText(/updated|success/i)).toBeVisible()
await page.goto('/entities')
await expect(page.getByText(`${entityName} (Updated)`)).toBeVisible()
})
test('deletes entity with confirmation', async ({ page }) => {
// Click delete button
const firstEntity = page.getByRole('listitem').first()
const entityName = await firstEntity.getByRole('heading').textContent()
await firstEntity.getByRole('button', { name: /delete/i }).click()
// Confirm deletion in dialog
const dialog = page.getByRole('dialog')
await expect(dialog.getByText(/confirm|sure/i)).toBeVisible()
await dialog.getByRole('button', { name: /delete|confirm/i }).click()
// Verify entity is removed
await expect(page.getByText(entityName!)).not.toBeVisible()
})
test('searches entities', async ({ page }) => {
// Enter search query
const searchInput = page.getByRole('searchbox', { name: /search/i })
await searchInput.fill('character')
// Wait for filtered results
await page.waitForTimeout(500) // Debounce
// Verify filtered results
const results = page.getByRole('listitem')
const count = await results.count()
// All visible results should match search
for (let i = 0; i < count; i++) {
const item = results.nth(i)
await expect(item.getByText(/character/i)).toBeVisible()
}
})
test('filters entities by type', async ({ page }) => {
// Select filter
await page.getByLabel(/filter by type/i).selectOption('location')
// Wait for filtered results
await page.waitForSelector('[role="listitem"]')
// Verify all results are locations
const badges = page.locator('.badge')
const count = await badges.count()
for (let i = 0; i < count; i++) {
await expect(badges.nth(i)).toHaveText('location')
}
})
test('keyboard navigation works', async ({ page }) => {
// Focus first interactive element
await page.keyboard.press('Tab')
// Navigate through entities with arrow keys
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
// Activate focused element with Enter
await page.keyboard.press('Enter')
// Verify navigation worked
await expect(page.getByRole('heading', { name: /entity/i })).toBeVisible()
})
test('meets accessibility standards', async ({ page }) => {
// Run axe accessibility scan
const accessibilityScanResults = await new AxeBuilder({ page }).analyze()
// Expect no violations
expect(accessibilityScanResults.violations).toEqual([])
})
test('is responsive on mobile', async ({ page }) => {
// Set mobile viewport
await page.setViewportSize({ width: 375, height: 667 })
// Verify mobile layout
await expect(page.getByRole('button', { name: /menu/i })).toBeVisible()
// Test mobile navigation
await page.getByRole('button', { name: /menu/i }).click()
await expect(page.getByRole('navigation')).toBeVisible()
})
})
test.describe('Entity Relationships', () => {
test('creates relationship between entities', async ({ page }) => {
// Navigate to first entity detail page
await page.goto('/entities')
await page.getByRole('listitem').first().click()
// Open relationship creation
await page.getByRole('button', { name: /add relationship/i }).click()
// Select related entity
await page.getByLabel(/related entity/i).fill('Location')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Enter')
// Select relationship type
await page.getByLabel(/relationship type/i).selectOption('lives_in')
// Save relationship
await page.getByRole('button', { name: /create|save/i }).click()
// Verify relationship appears
await expect(page.getByText(/lives_in/i)).toBeVisible()
})
test('relationship section is accessible', async ({ page }) => {
await page.goto('/entities')
await page.getByRole('listitem').first().click()
// Scan relationships section
const accessibilityScanResults = await new AxeBuilder({ page })
.include('#relationships')
.analyze()
expect(accessibilityScanResults.violations).toEqual([])
})
})

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
/**
* Example unit test demonstrating best practices
* for testing pure functions and business logic
*/
// Example function to test
function validateEntityName(name: string): { valid: boolean; error?: string } {
if (!name || name.trim().length === 0) {
return { valid: false, error: 'Name is required' }
}
if (name.length > 100) {
return { valid: false, error: 'Name must be 100 characters or less' }
}
if (!/^[a-zA-Z0-9\s-_']+$/.test(name)) {
return { valid: false, error: 'Name contains invalid characters' }
}
return { valid: true }
}
describe('validateEntityName', () => {
it('accepts valid entity names', () => {
expect(validateEntityName('John Doe').valid).toBe(true)
expect(validateEntityName("O'Brien").valid).toBe(true)
expect(validateEntityName('Location-123').valid).toBe(true)
})
it('rejects empty names', () => {
const result = validateEntityName('')
expect(result.valid).toBe(false)
expect(result.error).toBe('Name is required')
})
it('rejects names with only whitespace', () => {
const result = validateEntityName(' ')
expect(result.valid).toBe(false)
expect(result.error).toBe('Name is required')
})
it('rejects names exceeding max length', () => {
const longName = 'a'.repeat(101)
const result = validateEntityName(longName)
expect(result.valid).toBe(false)
expect(result.error).toContain('100 characters')
})
it('rejects names with invalid characters', () => {
const result = validateEntityName('Name@#$')
expect(result.valid).toBe(false)
expect(result.error).toContain('invalid characters')
})
})
// Example async function test
async function fetchEntityData(id: string): Promise<any> {
const response = await fetch(`/api/entities/${id}`)
if (!response.ok) throw new Error('Failed to fetch')
return response.json()
}
describe('fetchEntityData', () => {
beforeEach(() => {
global.fetch = vi.fn()
})
it('fetches entity data successfully', async () => {
const mockData = { id: '1', name: 'Test Entity' }
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockData
})
const result = await fetchEntityData('1')
expect(result).toEqual(mockData)
expect(global.fetch).toHaveBeenCalledWith('/api/entities/1')
})
it('throws error on failed fetch', async () => {
;(global.fetch as any).mockResolvedValueOnce({
ok: false
})
await expect(fetchEntityData('1')).rejects.toThrow('Failed to fetch')
})
})