1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
/**
|
|
* 草稿存储工具测试
|
|
* @jest-environment jsdom
|
|
*/
|
|
|
|
import {
|
|
saveDraft,
|
|
getDraft,
|
|
deleteDraft,
|
|
hasDraft,
|
|
checkConflict,
|
|
getAllDrafts,
|
|
cleanExpiredDrafts,
|
|
clearAllDrafts,
|
|
generateDraftId,
|
|
formatDraftTime,
|
|
DraftData,
|
|
} from '../draft-storage'
|
|
|
|
// Mock localStorage
|
|
const createLocalStorageMock = () => {
|
|
let store: Record<string, string> = {}
|
|
return {
|
|
getItem: jest.fn((key: string) => store[key] || null),
|
|
setItem: jest.fn((key: string, value: string) => {
|
|
store[key] = value
|
|
}),
|
|
removeItem: jest.fn((key: string) => {
|
|
delete store[key]
|
|
}),
|
|
clear: jest.fn(() => {
|
|
store = {}
|
|
}),
|
|
key: jest.fn((index: number) => Object.keys(store)[index] || null),
|
|
get length() {
|
|
return Object.keys(store).length
|
|
},
|
|
}
|
|
}
|
|
|
|
// Mock sessionStorage
|
|
const createSessionStorageMock = () => {
|
|
let store: Record<string, string> = {}
|
|
return {
|
|
getItem: jest.fn((key: string) => store[key] || null),
|
|
setItem: jest.fn((key: string, value: string) => {
|
|
store[key] = value
|
|
}),
|
|
removeItem: jest.fn((key: string) => {
|
|
delete store[key]
|
|
}),
|
|
clear: jest.fn(() => {
|
|
store = {}
|
|
}),
|
|
}
|
|
}
|
|
|
|
let localStorageMock: ReturnType<typeof createLocalStorageMock>
|
|
let sessionStorageMock: ReturnType<typeof createSessionStorageMock>
|
|
|
|
describe('draft-storage', () => {
|
|
beforeEach(() => {
|
|
localStorageMock = createLocalStorageMock()
|
|
sessionStorageMock = createSessionStorageMock()
|
|
|
|
Object.defineProperty(window, 'localStorage', {
|
|
value: localStorageMock,
|
|
writable: true,
|
|
configurable: true
|
|
})
|
|
Object.defineProperty(window, 'sessionStorage', {
|
|
value: sessionStorageMock,
|
|
writable: true,
|
|
configurable: true
|
|
})
|
|
|
|
jest.clearAllMocks()
|
|
})
|
|
|
|
describe('generateDraftId', () => {
|
|
it('should generate id with entity id', () => {
|
|
const id = generateDraftId('material-upload', 'mat123')
|
|
expect(id).toBe('material-upload_mat123')
|
|
})
|
|
|
|
it('should generate id without entity id', () => {
|
|
const id = generateDraftId('material-upload')
|
|
expect(id).toMatch(/^material-upload_new_\d+$/)
|
|
})
|
|
})
|
|
|
|
describe('saveDraft', () => {
|
|
it('should save draft to localStorage', () => {
|
|
const data = { field1: 'value1', field2: 123 }
|
|
const result = saveDraft('test-draft', 'test-form', data)
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(localStorageMock.setItem).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should include all required fields', () => {
|
|
const data = { field1: 'value1' }
|
|
saveDraft('test-draft', 'test-form', data)
|
|
|
|
const savedData = JSON.parse(localStorageMock.setItem.mock.calls[0][1])
|
|
expect(savedData.id).toBe('test-draft')
|
|
expect(savedData.formType).toBe('test-form')
|
|
expect(savedData.data).toEqual(data)
|
|
expect(savedData.createdAt).toBeDefined()
|
|
expect(savedData.updatedAt).toBeDefined()
|
|
expect(savedData.tabId).toBeDefined()
|
|
expect(savedData.version).toBe(1)
|
|
})
|
|
|
|
it('should increment version for existing draft from other tab', () => {
|
|
// 先保存一个草稿
|
|
const existingDraft: DraftData = {
|
|
id: 'test-draft',
|
|
formType: 'test-form',
|
|
data: { old: 'data' },
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
tabId: 'other-tab',
|
|
version: 1,
|
|
}
|
|
localStorageMock.setItem('flnxi_draft_test-draft', JSON.stringify(existingDraft))
|
|
|
|
// 从当前标签页保存
|
|
saveDraft('test-draft', 'test-form', { new: 'data' })
|
|
|
|
const savedData = JSON.parse(localStorageMock.setItem.mock.calls[1][1])
|
|
expect(savedData.version).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('getDraft', () => {
|
|
it('should return null for non-existent draft', () => {
|
|
const result = getDraft('non-existent')
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('should return draft data', () => {
|
|
const draft: DraftData = {
|
|
id: 'test-draft',
|
|
formType: 'test-form',
|
|
data: { field1: 'value1' },
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
tabId: 'tab1',
|
|
version: 1,
|
|
}
|
|
localStorageMock.setItem('flnxi_draft_test-draft', JSON.stringify(draft))
|
|
|
|
const result = getDraft('test-draft')
|
|
expect(result).not.toBeNull()
|
|
expect(result?.data).toEqual({ field1: 'value1' })
|
|
})
|
|
|
|
it('should return null for expired draft', () => {
|
|
const oldDate = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000) // 8 days ago
|
|
const draft: DraftData = {
|
|
id: 'test-draft',
|
|
formType: 'test-form',
|
|
data: { field1: 'value1' },
|
|
createdAt: oldDate.toISOString(),
|
|
updatedAt: oldDate.toISOString(),
|
|
tabId: 'tab1',
|
|
version: 1,
|
|
}
|
|
localStorageMock.setItem('flnxi_draft_test-draft', JSON.stringify(draft))
|
|
|
|
const result = getDraft('test-draft')
|
|
expect(result).toBeNull()
|
|
expect(localStorageMock.removeItem).toHaveBeenCalledWith('flnxi_draft_test-draft')
|
|
})
|
|
})
|
|
|
|
describe('deleteDraft', () => {
|
|
it('should delete draft from localStorage', () => {
|
|
localStorageMock.setItem('flnxi_draft_test-draft', '{}')
|
|
|
|
const result = deleteDraft('test-draft')
|
|
|
|
expect(result).toBe(true)
|
|
expect(localStorageMock.removeItem).toHaveBeenCalledWith('flnxi_draft_test-draft')
|
|
})
|
|
})
|
|
|
|
describe('hasDraft', () => {
|
|
it('should return false for non-existent draft', () => {
|
|
expect(hasDraft('non-existent')).toBe(false)
|
|
})
|
|
|
|
it('should return true for existing draft', () => {
|
|
const draft: DraftData = {
|
|
id: 'test-draft',
|
|
formType: 'test-form',
|
|
data: {},
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
tabId: 'tab1',
|
|
version: 1,
|
|
}
|
|
localStorageMock.setItem('flnxi_draft_test-draft', JSON.stringify(draft))
|
|
|
|
expect(hasDraft('test-draft')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('checkConflict', () => {
|
|
it('should return no conflict for non-existent draft', () => {
|
|
const result = checkConflict('non-existent')
|
|
expect(result.hasConflict).toBe(false)
|
|
})
|
|
|
|
it('should return no conflict for same tab', () => {
|
|
// 设置当前标签页ID
|
|
sessionStorageMock.setItem('flnxi_tab_id', 'current-tab')
|
|
|
|
const draft: DraftData = {
|
|
id: 'test-draft',
|
|
formType: 'test-form',
|
|
data: {},
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
tabId: 'current-tab',
|
|
version: 1,
|
|
}
|
|
localStorageMock.setItem('flnxi_draft_test-draft', JSON.stringify(draft))
|
|
|
|
const result = checkConflict('test-draft')
|
|
expect(result.hasConflict).toBe(false)
|
|
})
|
|
|
|
it('should return conflict for different tab', () => {
|
|
// 设置当前标签页ID
|
|
sessionStorageMock.setItem('flnxi_tab_id', 'current-tab')
|
|
|
|
const draft: DraftData = {
|
|
id: 'test-draft',
|
|
formType: 'test-form',
|
|
data: { field: 'value' },
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
tabId: 'other-tab',
|
|
version: 1,
|
|
}
|
|
localStorageMock.setItem('flnxi_draft_test-draft', JSON.stringify(draft))
|
|
|
|
const result = checkConflict('test-draft')
|
|
expect(result.hasConflict).toBe(true)
|
|
expect(result.otherTabDraft).toBeDefined()
|
|
expect(result.otherTabDraft?.data).toEqual({ field: 'value' })
|
|
})
|
|
})
|
|
|
|
describe('getAllDrafts', () => {
|
|
it('should return empty array when no drafts', () => {
|
|
const result = getAllDrafts()
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('should return all drafts', () => {
|
|
const draft1: DraftData = {
|
|
id: 'draft1',
|
|
formType: 'form1',
|
|
data: {},
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date(Date.now() - 1000).toISOString(),
|
|
tabId: 'tab1',
|
|
version: 1,
|
|
}
|
|
const draft2: DraftData = {
|
|
id: 'draft2',
|
|
formType: 'form2',
|
|
data: {},
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
tabId: 'tab1',
|
|
version: 1,
|
|
}
|
|
localStorageMock.setItem('flnxi_draft_draft1', JSON.stringify(draft1))
|
|
localStorageMock.setItem('flnxi_draft_draft2', JSON.stringify(draft2))
|
|
|
|
const result = getAllDrafts()
|
|
expect(result).toHaveLength(2)
|
|
// Should be sorted by updatedAt descending
|
|
expect(result[0].id).toBe('draft2')
|
|
})
|
|
|
|
it('should filter by formType', () => {
|
|
const draft1: DraftData = {
|
|
id: 'draft1',
|
|
formType: 'form1',
|
|
data: {},
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
tabId: 'tab1',
|
|
version: 1,
|
|
}
|
|
const draft2: DraftData = {
|
|
id: 'draft2',
|
|
formType: 'form2',
|
|
data: {},
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
tabId: 'tab1',
|
|
version: 1,
|
|
}
|
|
localStorageMock.setItem('flnxi_draft_draft1', JSON.stringify(draft1))
|
|
localStorageMock.setItem('flnxi_draft_draft2', JSON.stringify(draft2))
|
|
|
|
const result = getAllDrafts('form1')
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].formType).toBe('form1')
|
|
})
|
|
})
|
|
|
|
describe('clearAllDrafts', () => {
|
|
it('should clear all drafts', () => {
|
|
const draft1: DraftData = {
|
|
id: 'draft1',
|
|
formType: 'form1',
|
|
data: {},
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
tabId: 'tab1',
|
|
version: 1,
|
|
}
|
|
localStorageMock.setItem('flnxi_draft_draft1', JSON.stringify(draft1))
|
|
localStorageMock.setItem('other_key', 'other_value')
|
|
|
|
const count = clearAllDrafts()
|
|
|
|
expect(count).toBe(1)
|
|
expect(localStorageMock.removeItem).toHaveBeenCalledWith('flnxi_draft_draft1')
|
|
expect(localStorageMock.removeItem).not.toHaveBeenCalledWith('other_key')
|
|
})
|
|
|
|
it('should clear drafts by formType', () => {
|
|
const draft1: DraftData = {
|
|
id: 'draft1',
|
|
formType: 'form1',
|
|
data: {},
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
tabId: 'tab1',
|
|
version: 1,
|
|
}
|
|
const draft2: DraftData = {
|
|
id: 'draft2',
|
|
formType: 'form2',
|
|
data: {},
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
tabId: 'tab1',
|
|
version: 1,
|
|
}
|
|
localStorageMock.setItem('flnxi_draft_draft1', JSON.stringify(draft1))
|
|
localStorageMock.setItem('flnxi_draft_draft2', JSON.stringify(draft2))
|
|
|
|
const count = clearAllDrafts('form1')
|
|
|
|
expect(count).toBe(1)
|
|
expect(localStorageMock.removeItem).toHaveBeenCalledWith('flnxi_draft_draft1')
|
|
})
|
|
})
|
|
|
|
describe('formatDraftTime', () => {
|
|
it('should format recent time as "刚刚"', () => {
|
|
const now = new Date().toISOString()
|
|
expect(formatDraftTime(now)).toBe('刚刚')
|
|
})
|
|
|
|
it('should format minutes ago', () => {
|
|
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
|
|
expect(formatDraftTime(fiveMinutesAgo)).toBe('5分钟前')
|
|
})
|
|
|
|
it('should format hours ago', () => {
|
|
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString()
|
|
expect(formatDraftTime(twoHoursAgo)).toBe('2小时前')
|
|
})
|
|
|
|
it('should format days ago', () => {
|
|
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString()
|
|
expect(formatDraftTime(threeDaysAgo)).toBe('3天前')
|
|
})
|
|
|
|
it('should format old dates as date string', () => {
|
|
const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString()
|
|
const result = formatDraftTime(twoWeeksAgo)
|
|
// Should be a date string like "2024/1/1"
|
|
expect(result).toMatch(/^\d{4}\/\d{1,2}\/\d{1,2}$/)
|
|
})
|
|
})
|
|
})
|