1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
394 lines
11 KiB
TypeScript
394 lines
11 KiB
TypeScript
/**
|
|
* 分类管理批量操作和同步功能的单元测试
|
|
*
|
|
* @jest-environment node
|
|
* @created 2024-12-13
|
|
*/
|
|
|
|
import {
|
|
parseCSVOptions,
|
|
parseJSONOptions,
|
|
exportToCSV,
|
|
exportToJSON,
|
|
type ImportedOption,
|
|
type ExportedField,
|
|
} from '@/lib/category-batch-operations'
|
|
|
|
import {
|
|
getSyncLogs,
|
|
clearSyncLogs,
|
|
} from '@/lib/category-sync'
|
|
|
|
describe('Category Batch Operations', () => {
|
|
describe('parseCSVOptions', () => {
|
|
it('should parse simple CSV content', () => {
|
|
const csv = `fieldKey,value,label
|
|
categoryName,店铺A,店铺A标签
|
|
categoryName,店铺B,店铺B标签
|
|
sceneName,场景1,`
|
|
|
|
const result = parseCSVOptions(csv)
|
|
|
|
expect(result).toHaveLength(3)
|
|
expect(result[0]).toEqual({
|
|
fieldKey: 'categoryName',
|
|
value: '店铺A',
|
|
label: '店铺A标签',
|
|
})
|
|
expect(result[1]).toEqual({
|
|
fieldKey: 'categoryName',
|
|
value: '店铺B',
|
|
label: '店铺B标签',
|
|
})
|
|
expect(result[2]).toEqual({
|
|
fieldKey: 'sceneName',
|
|
value: '场景1',
|
|
label: undefined,
|
|
})
|
|
})
|
|
|
|
it('should handle CSV without header', () => {
|
|
const csv = `categoryName,店铺A,店铺A标签
|
|
categoryName,店铺B,`
|
|
|
|
const result = parseCSVOptions(csv)
|
|
|
|
expect(result).toHaveLength(2)
|
|
expect(result[0].fieldKey).toBe('categoryName')
|
|
})
|
|
|
|
it('should handle quoted values with commas', () => {
|
|
const csv = `fieldKey,value,label
|
|
categoryName,"店铺A,分店",标签`
|
|
|
|
const result = parseCSVOptions(csv)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].value).toBe('店铺A,分店')
|
|
})
|
|
|
|
it('should handle empty lines', () => {
|
|
const csv = `fieldKey,value,label
|
|
|
|
categoryName,店铺A,标签
|
|
|
|
sceneName,场景1,`
|
|
|
|
const result = parseCSVOptions(csv)
|
|
|
|
expect(result).toHaveLength(2)
|
|
})
|
|
|
|
it('should handle escaped quotes', () => {
|
|
const csv = `fieldKey,value,label
|
|
categoryName,"店铺""A""",标签`
|
|
|
|
const result = parseCSVOptions(csv)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].value).toBe('店铺"A"')
|
|
})
|
|
})
|
|
|
|
describe('parseJSONOptions', () => {
|
|
it('should parse array format', () => {
|
|
const json = JSON.stringify([
|
|
{ fieldKey: 'categoryName', value: '店铺A', label: '标签A' },
|
|
{ fieldKey: 'categoryName', value: '店铺B' },
|
|
{ fieldKey: 'sceneName', value: '场景1' },
|
|
])
|
|
|
|
const result = parseJSONOptions(json)
|
|
|
|
expect(result).toHaveLength(3)
|
|
expect(result[0]).toEqual({
|
|
fieldKey: 'categoryName',
|
|
value: '店铺A',
|
|
label: '标签A',
|
|
})
|
|
expect(result[1].label).toBeUndefined()
|
|
})
|
|
|
|
it('should parse object format', () => {
|
|
const json = JSON.stringify({
|
|
categoryName: [
|
|
{ value: '店铺A', label: '标签A' },
|
|
{ value: '店铺B' },
|
|
],
|
|
sceneName: ['场景1', '场景2'],
|
|
})
|
|
|
|
const result = parseJSONOptions(json)
|
|
|
|
expect(result).toHaveLength(4)
|
|
expect(result.filter(o => o.fieldKey === 'categoryName')).toHaveLength(2)
|
|
expect(result.filter(o => o.fieldKey === 'sceneName')).toHaveLength(2)
|
|
})
|
|
|
|
it('should handle invalid JSON', () => {
|
|
const result = parseJSONOptions('invalid json')
|
|
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('should filter out invalid entries', () => {
|
|
const json = JSON.stringify([
|
|
{ fieldKey: 'categoryName', value: '店铺A' },
|
|
{ fieldKey: 'categoryName', value: '店铺B' },
|
|
{ fieldKey: 'categoryName' }, // 缺少value - 应被过滤
|
|
{ value: '店铺C' }, // 缺少fieldKey - 应被过滤
|
|
])
|
|
|
|
const result = parseJSONOptions(json)
|
|
|
|
// 只有前两个有效条目应该被保留
|
|
expect(result).toHaveLength(2)
|
|
expect(result[0].value).toBe('店铺A')
|
|
expect(result[1].value).toBe('店铺B')
|
|
})
|
|
})
|
|
|
|
describe('exportToCSV', () => {
|
|
it('should export fields to CSV format', () => {
|
|
const fields: ExportedField[] = [
|
|
{
|
|
key: 'categoryName',
|
|
label: '使用店铺',
|
|
filterDisplayName: null,
|
|
filterOrder: 0,
|
|
isFilterEnabled: true,
|
|
options: [
|
|
{ value: '店铺A', label: '标签A' },
|
|
{ value: '店铺B', label: null },
|
|
],
|
|
},
|
|
{
|
|
key: 'sceneName',
|
|
label: '场景类别',
|
|
filterDisplayName: '场景',
|
|
filterOrder: 1,
|
|
isFilterEnabled: true,
|
|
options: [
|
|
{ value: '场景1', label: null },
|
|
],
|
|
},
|
|
]
|
|
|
|
const csv = exportToCSV(fields)
|
|
const lines = csv.split('\n')
|
|
|
|
expect(lines[0]).toBe('fieldKey,value,label')
|
|
expect(lines[1]).toBe('categoryName,店铺A,标签A')
|
|
expect(lines[2]).toBe('categoryName,店铺B,')
|
|
expect(lines[3]).toBe('sceneName,场景1,')
|
|
})
|
|
|
|
it('should escape special characters', () => {
|
|
const fields: ExportedField[] = [
|
|
{
|
|
key: 'test',
|
|
label: 'Test',
|
|
filterDisplayName: null,
|
|
filterOrder: 0,
|
|
isFilterEnabled: true,
|
|
options: [
|
|
{ value: '值,带逗号', label: null },
|
|
{ value: '值"带引号"', label: null },
|
|
],
|
|
},
|
|
]
|
|
|
|
const csv = exportToCSV(fields)
|
|
const lines = csv.split('\n')
|
|
|
|
expect(lines[1]).toBe('test,"值,带逗号",')
|
|
expect(lines[2]).toBe('test,"值""带引号""",')
|
|
})
|
|
})
|
|
|
|
describe('exportToJSON', () => {
|
|
it('should export fields to JSON format', () => {
|
|
const fields: ExportedField[] = [
|
|
{
|
|
key: 'categoryName',
|
|
label: '使用店铺',
|
|
filterDisplayName: null,
|
|
filterOrder: 0,
|
|
isFilterEnabled: true,
|
|
options: [
|
|
{ value: '店铺A', label: '标签A' },
|
|
],
|
|
},
|
|
]
|
|
|
|
const json = exportToJSON(fields)
|
|
const parsed = JSON.parse(json)
|
|
|
|
expect(parsed).toHaveLength(1)
|
|
expect(parsed[0].key).toBe('categoryName')
|
|
expect(parsed[0].options).toHaveLength(1)
|
|
})
|
|
|
|
it('should produce valid JSON', () => {
|
|
const fields: ExportedField[] = [
|
|
{
|
|
key: 'test',
|
|
label: 'Test',
|
|
filterDisplayName: null,
|
|
filterOrder: 0,
|
|
isFilterEnabled: true,
|
|
options: [
|
|
{ value: '值"带引号"', label: null },
|
|
],
|
|
},
|
|
]
|
|
|
|
const json = exportToJSON(fields)
|
|
|
|
expect(() => JSON.parse(json)).not.toThrow()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Category Sync', () => {
|
|
beforeEach(() => {
|
|
clearSyncLogs()
|
|
})
|
|
|
|
describe('getSyncLogs', () => {
|
|
it('should return empty array initially', () => {
|
|
const logs = getSyncLogs()
|
|
expect(logs).toEqual([])
|
|
})
|
|
|
|
it('should respect limit parameter', () => {
|
|
const logs = getSyncLogs(10)
|
|
expect(logs.length).toBeLessThanOrEqual(10)
|
|
})
|
|
})
|
|
|
|
describe('clearSyncLogs', () => {
|
|
it('should clear all logs', () => {
|
|
clearSyncLogs()
|
|
const logs = getSyncLogs()
|
|
expect(logs).toEqual([])
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Batch Operation Logic', () => {
|
|
it('should correctly identify fields to toggle', () => {
|
|
const fieldKeys = ['categoryName', 'sceneName', 'platformName']
|
|
const enabled = true
|
|
|
|
// 模拟批量操作逻辑
|
|
const operations = fieldKeys.map(key => ({
|
|
key,
|
|
data: { isFilterEnabled: enabled },
|
|
}))
|
|
|
|
expect(operations).toHaveLength(3)
|
|
expect(operations[0].data.isFilterEnabled).toBe(true)
|
|
})
|
|
|
|
it('should group options by field for import', () => {
|
|
const options: ImportedOption[] = [
|
|
{ fieldKey: 'categoryName', value: '店铺A' },
|
|
{ fieldKey: 'categoryName', value: '店铺B' },
|
|
{ fieldKey: 'sceneName', value: '场景1' },
|
|
]
|
|
|
|
const grouped = new Map<string, ImportedOption[]>()
|
|
for (const option of options) {
|
|
const existing = grouped.get(option.fieldKey) || []
|
|
existing.push(option)
|
|
grouped.set(option.fieldKey, existing)
|
|
}
|
|
|
|
expect(grouped.size).toBe(2)
|
|
expect(grouped.get('categoryName')).toHaveLength(2)
|
|
expect(grouped.get('sceneName')).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('Sync Logic', () => {
|
|
it('should detect option changes correctly', () => {
|
|
const existingOptions = ['A', 'B', 'C']
|
|
const newOptions = ['A', 'B', 'D']
|
|
|
|
const added = newOptions.filter(opt => !existingOptions.includes(opt))
|
|
const removed = existingOptions.filter(opt => !newOptions.includes(opt))
|
|
|
|
expect(added).toEqual(['D'])
|
|
expect(removed).toEqual(['C'])
|
|
})
|
|
|
|
it('should handle option rename correctly', () => {
|
|
const options = ['A', 'B', 'C']
|
|
const oldValue = 'B'
|
|
const newValue = 'B-renamed'
|
|
|
|
const updated = options.map(opt => opt === oldValue ? newValue : opt)
|
|
|
|
expect(updated).toEqual(['A', 'B-renamed', 'C'])
|
|
})
|
|
|
|
it('should handle option delete correctly', () => {
|
|
const options = ['A', 'B', 'C']
|
|
const toDelete = 'B'
|
|
|
|
const filtered = options.filter(opt => opt !== toDelete)
|
|
|
|
expect(filtered).toEqual(['A', 'C'])
|
|
})
|
|
|
|
it('should detect version conflicts', () => {
|
|
const originalUpdatedAt = new Date('2024-01-01')
|
|
const currentUpdatedAt = new Date('2024-01-02')
|
|
|
|
const hasConflict = currentUpdatedAt > originalUpdatedAt
|
|
|
|
expect(hasConflict).toBe(true)
|
|
})
|
|
|
|
it('should merge options without duplicates', () => {
|
|
const existingOptions = ['A', 'B', 'C']
|
|
const newOptions = ['B', 'C', 'D', 'E']
|
|
|
|
const existingSet = new Set(existingOptions)
|
|
const toAdd = newOptions.filter(opt => !existingSet.has(opt))
|
|
|
|
expect(toAdd).toEqual(['D', 'E'])
|
|
})
|
|
})
|
|
|
|
describe('Impact Analysis', () => {
|
|
it('should generate correct warning message', () => {
|
|
const templateCount = 3
|
|
const templateNames = ['模板A', '模板B', '模板C']
|
|
const optionCount = 10
|
|
|
|
let warning = ''
|
|
if (templateCount > 0) {
|
|
warning = `该字段被 ${templateCount} 个模板使用(${templateNames.slice(0, 3).join('、')}${templateCount > 3 ? '等' : ''}),删除后这些模板将失去该字段。`
|
|
}
|
|
if (optionCount > 0) {
|
|
warning += warning ? '\n' : ''
|
|
warning += `该字段有 ${optionCount} 个选项将被一并删除。`
|
|
}
|
|
|
|
expect(warning).toContain('3 个模板')
|
|
expect(warning).toContain('模板A、模板B、模板C')
|
|
expect(warning).toContain('10 个选项')
|
|
})
|
|
|
|
it('should truncate long template list', () => {
|
|
const templateNames = ['模板A', '模板B', '模板C', '模板D', '模板E']
|
|
const templateCount = templateNames.length
|
|
|
|
const displayNames = templateNames.slice(0, 3).join('、') + (templateCount > 3 ? '等' : '')
|
|
|
|
expect(displayNames).toBe('模板A、模板B、模板C等')
|
|
})
|
|
})
|