feat: 添加测试框架和优化项目结构

- 添加 Jest 配置和测试设置
- 添加 use-categories hook 的单元测试
- 更新 CLAUDE.md 添加包管理工具说明
- 优化首页、视频页和频道页的组件结构
- 添加 .claude 命令配置文件
- 移除 bun.lock 和 package-lock.json,统一使用 bun
- 更新 package.json 依赖

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
imeepos
2026-01-19 10:53:54 +08:00
parent 5d016ac812
commit 1f9b6e22d6
19 changed files with 1828 additions and 17084 deletions

View File

@@ -0,0 +1,417 @@
import { renderHook, act, waitFor } from '@testing-library/react'
import { useCategories } from './use-categories'
import { useCategoriesStore } from './use-categories'
import { root } from '@repo/core'
import { CategoryController } from '@repo/sdk'
import { handleError } from './use-error'
import { OWNER_ID } from '@/lib/auth'
// Mock dependencies
jest.mock('@repo/core', () => ({
root: {
get: jest.fn(),
},
}))
jest.mock('./use-error', () => ({
handleError: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
OWNER_ID: 'test-owner-id',
}))
describe('useCategories', () => {
beforeEach(() => {
jest.clearAllMocks()
// Reset the store state before each test
useCategoriesStore.setState({
data: undefined,
loading: false,
error: null,
hasLoaded: false,
})
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('initial state', () => {
it('should return initial state with no data loaded', () => {
const mockCategoryController = {
list: jest.fn(),
}
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
;(handleError as jest.Mock).mockResolvedValue({
data: undefined,
error: null,
})
const { result } = renderHook(() => useCategories())
expect(result.current.data).toBeUndefined()
expect(result.current.error).toBeNull()
})
it('should have loading state initially false', () => {
const mockCategoryController = {
list: jest.fn(),
}
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
;(handleError as jest.Mock).mockResolvedValue({
data: undefined,
error: null,
})
const { result } = renderHook(() => useCategories())
expect(result.current.loading).toBe(false)
})
})
describe('load function', () => {
it('should load categories successfully', async () => {
const mockData = {
categories: [
{ id: '1', name: 'Category 1' },
{ id: '2', name: 'Category 2' },
],
total: 2,
page: 1,
limit: 1000,
}
const mockCategoryController = {
list: jest.fn().mockResolvedValue(mockData),
}
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
;(handleError as jest.Mock).mockResolvedValue({
data: mockData,
error: null,
})
const { result } = renderHook(() => useCategories())
await act(async () => {
await result.current.load()
})
expect(mockCategoryController.list).toHaveBeenCalledWith({
page: 1,
limit: 1000,
isActive: true,
orderBy: 'sortOrder',
order: 'desc',
withChildren: true,
ownerId: OWNER_ID,
})
expect(result.current.data).toEqual(mockData)
expect(result.current.error).toBeNull()
})
it('should handle API errors', async () => {
const mockError = {
status: 500,
statusText: 'Internal Server Error',
message: 'Failed to load categories',
}
const mockCategoryController = {
list: jest.fn(),
}
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
;(handleError as jest.Mock).mockResolvedValue({
data: null,
error: mockError,
})
const { result } = renderHook(() => useCategories())
await act(async () => {
await result.current.load()
})
expect(result.current.error).toEqual(mockError)
expect(result.current.data).toBeUndefined()
})
it('should handle unexpected errors', async () => {
const mockError = new Error('Network error')
const mockCategoryController = {
list: jest.fn().mockRejectedValue(mockError),
}
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
;(handleError as jest.Mock).mockResolvedValue({
data: null,
error: null,
})
const { result } = renderHook(() => useCategories())
await act(async () => {
await result.current.load()
})
// When handleError returns no error but an exception occurs
expect(result.current.error).toBeDefined()
})
it('should merge custom params with default params', async () => {
const mockData = { categories: [], total: 0, page: 1, limit: 1000 }
const customParams = { page: 2, limit: 20 }
const mockCategoryController = {
list: jest.fn().mockResolvedValue(mockData),
}
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
;(handleError as jest.Mock).mockResolvedValue({
data: mockData,
error: null,
})
const { result } = renderHook(() => useCategories())
await act(async () => {
await result.current.load(customParams)
})
expect(mockCategoryController.list).toHaveBeenCalledWith({
page: 2,
limit: 20,
isActive: true,
orderBy: 'sortOrder',
order: 'desc',
withChildren: true,
ownerId: OWNER_ID,
})
})
it('should not load if already loading', async () => {
const mockCategoryController = {
list: jest.fn().mockResolvedValue({ categories: [], total: 0 }),
}
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
;(handleError as jest.Mock).mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ data: { categories: [], total: 0 }, error: null }), 100)
}),
)
const { result } = renderHook(() => useCategories())
// Start first load
const firstLoad = act(async () => {
await result.current.load()
})
// Try to load again while first is still loading
act(() => {
result.current.load()
})
await firstLoad
// Should only call list once due to loading check
expect(mockCategoryController.list).toHaveBeenCalledTimes(1)
})
})
describe('loading state', () => {
it('should set loading to true during fetch', async () => {
let resolveFetch: (value: any) => void
const fetchPromise = new Promise((resolve) => {
resolveFetch = resolve
})
const mockCategoryController = {
list: jest.fn().mockReturnValue(fetchPromise),
}
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
;(handleError as jest.Mock).mockReturnValue({
data: null,
error: null,
})
const { result } = renderHook(() => useCategories())
act(() => {
result.current.load()
})
expect(result.current.loading).toBe(true)
await act(async () => {
resolveFetch!({ categories: [], total: 0 })
await fetchPromise
})
expect(result.current.loading).toBe(false)
})
it('should set loading to false after error', async () => {
const mockError = {
status: 500,
statusText: 'Error',
message: 'Test error',
}
const mockCategoryController = {
list: jest.fn(),
}
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
;(handleError as jest.Mock).mockResolvedValue({
data: null,
error: mockError,
})
const { result } = renderHook(() => useCategories())
await act(async () => {
await result.current.load()
})
expect(result.current.loading).toBe(false)
})
})
describe('initial load on mount', () => {
it('should auto-load on mount if not loaded', async () => {
const mockData = { categories: [{ id: '1' }], total: 1, page: 1, limit: 1000 }
const mockCategoryController = {
list: jest.fn().mockResolvedValue(mockData),
}
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
;(handleError as jest.Mock).mockResolvedValue({
data: mockData,
error: null,
})
const { result } = renderHook(() => useCategories())
await waitFor(() => {
expect(result.current.data).toEqual(mockData)
})
expect(mockCategoryController.list).toHaveBeenCalled()
})
it('should not auto-load if already loaded', async () => {
const mockData = { categories: [{ id: '1' }], total: 1, page: 1, limit: 1000 }
const mockCategoryController = {
list: jest.fn().mockResolvedValue(mockData),
}
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
;(handleError as jest.Mock).mockResolvedValue({
data: mockData,
error: null,
})
// Set store to already loaded state
useCategoriesStore.setState({
data: mockData,
loading: false,
error: null,
hasLoaded: true,
})
const { result } = renderHook(() => useCategories())
// Should not call list again since hasLoaded is true
expect(mockCategoryController.list).not.toHaveBeenCalled()
expect(result.current.data).toEqual(mockData)
})
})
describe('state persistence', () => {
it('should share state across hook instances', async () => {
const mockData = { categories: [{ id: '1' }], total: 1, page: 1, limit: 1000 }
const mockCategoryController = {
list: jest.fn().mockResolvedValue(mockData),
}
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
;(handleError as jest.Mock).mockResolvedValue({
data: mockData,
error: null,
})
const { result: result1 } = renderHook(() => useCategories())
const { result: result2 } = renderHook(() => useCategories())
await act(async () => {
await result1.current.load()
})
// Both hook instances should share the same store state
expect(result2.current.data).toEqual(mockData)
expect(result1.current.data).toBe(result2.current.data)
})
})
describe('params handling', () => {
it('should use initial params when calling load without arguments', async () => {
const mockData = { categories: [], total: 0, page: 1, limit: 1000 }
const initialParams = { isActive: false }
const mockCategoryController = {
list: jest.fn().mockResolvedValue(mockData),
}
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
;(handleError as jest.Mock).mockResolvedValue({
data: mockData,
error: null,
})
const { result } = renderHook(() => useCategories(initialParams))
await act(async () => {
await result.current.load()
})
expect(mockCategoryController.list).toHaveBeenCalledWith({
page: 1,
limit: 1000,
isActive: false, // Initial params should override default
orderBy: 'sortOrder',
order: 'desc',
withChildren: true,
ownerId: OWNER_ID,
})
})
it('should override initial params when load is called with arguments', async () => {
const mockData = { categories: [], total: 0, page: 1, limit: 1000 }
const initialParams = { isActive: true }
const mockCategoryController = {
list: jest.fn().mockResolvedValue(mockData),
}
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
;(handleError as jest.Mock).mockResolvedValue({
data: mockData,
error: null,
})
const { result } = renderHook(() => useCategories(initialParams))
await act(async () => {
await result.current.load({ isActive: false })
})
expect(mockCategoryController.list).toHaveBeenCalledWith({
page: 1,
limit: 1000,
isActive: false, // Load params should override initial
orderBy: 'sortOrder',
order: 'desc',
withChildren: true,
ownerId: OWNER_ID,
})
})
})
})

View File

@@ -1,21 +1,42 @@
import { root } from '@repo/core'
import { CategoryController, type ListCategoriesInput, type ListCategoriesResult } from '@repo/sdk'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { create } from 'zustand'
import { type ApiError } from '@/lib/types'
import { OWNER_ID } from '@/lib/auth'
import { handleError } from './use-error'
interface CategoriesState {
data: ListCategoriesResult | undefined
loading: boolean
error: ApiError | null
hasLoaded: boolean
}
export const useCategories = () => {
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<ApiError | null>(null)
const [data, setData] = useState<ListCategoriesResult>()
const useCategoriesStore = create<CategoriesState>((set) => ({
data: undefined,
loading: false,
error: null,
hasLoaded: false,
}))
export const useCategories = (params?: ListCategoriesInput) => {
const store = useCategoriesStore()
const [localLoading, setLocalLoading] = useState(false)
useEffect(() => {
if (!store.hasLoaded && !store.loading && !localLoading) {
load()
}
}, [])
const load = async (inputParams?: ListCategoriesInput) => {
if (store.loading || localLoading) return
const load = async (params?: ListCategoriesInput) => {
try {
setLoading(true)
setLocalLoading(true)
const category = root.get(CategoryController)
const { data, error } = await handleError(
async () =>
@@ -27,26 +48,26 @@ export const useCategories = () => {
order: 'desc',
withChildren: true,
ownerId: OWNER_ID,
...params,
...(inputParams ?? params),
}),
)
if (error) {
setError(error)
useCategoriesStore.setState({ error, loading: false, hasLoaded: true })
return
}
setData(data)
useCategoriesStore.setState({ data, loading: false, error: null, hasLoaded: true })
} catch (e) {
setError(e as ApiError)
useCategoriesStore.setState({ error: e as ApiError, loading: false, hasLoaded: true })
} finally {
setLoading(false)
setLocalLoading(false)
}
}
return {
load,
loading,
error,
data,
loading: store.loading || localLoading,
error: store.error,
data: store.data,
}
}