|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* Unified Tags Editing - Pure Logic Tests |
|
|
|
|
|
* |
|
|
|
|
|
* This test file validates the core business logic and state management |
|
|
|
|
|
* behaviors introduced in the recent 7 commits without requiring complex mocks. |
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
|
|
describe('Unified Tags Editing - Pure Logic Tests', () => { |
|
|
|
|
|
describe('Tag State Management Logic', () => { |
|
|
|
|
|
it('should detect when tag values have changed', () => { |
|
|
|
|
|
const currentValue = ['tag1', 'tag2'] |
|
|
|
|
|
const newSelectedTagIDs = ['tag1', 'tag3'] |
|
|
|
|
|
|
|
|
|
|
|
// This is the valueNotChanged logic from TagSelector component |
|
|
|
|
|
const valueNotChanged |
|
|
|
|
|
= currentValue.length === newSelectedTagIDs.length |
|
|
|
|
|
&& currentValue.every(v => newSelectedTagIDs.includes(v)) |
|
|
|
|
|
&& newSelectedTagIDs.every(v => currentValue.includes(v)) |
|
|
|
|
|
|
|
|
|
|
|
expect(valueNotChanged).toBe(false) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should correctly identify unchanged tag values', () => { |
|
|
|
|
|
const currentValue = ['tag1', 'tag2'] |
|
|
|
|
|
const newSelectedTagIDs = ['tag2', 'tag1'] // Same tags, different order |
|
|
|
|
|
|
|
|
|
|
|
const valueNotChanged |
|
|
|
|
|
= currentValue.length === newSelectedTagIDs.length |
|
|
|
|
|
&& currentValue.every(v => newSelectedTagIDs.includes(v)) |
|
|
|
|
|
&& newSelectedTagIDs.every(v => currentValue.includes(v)) |
|
|
|
|
|
|
|
|
|
|
|
expect(valueNotChanged).toBe(true) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should calculate correct tag operations for binding/unbinding', () => { |
|
|
|
|
|
const currentValue = ['tag1', 'tag2'] |
|
|
|
|
|
const selectedTagIDs = ['tag2', 'tag3'] |
|
|
|
|
|
|
|
|
|
|
|
// This is the handleValueChange logic from TagSelector |
|
|
|
|
|
const addTagIDs = selectedTagIDs.filter(v => !currentValue.includes(v)) |
|
|
|
|
|
const removeTagIDs = currentValue.filter(v => !selectedTagIDs.includes(v)) |
|
|
|
|
|
|
|
|
|
|
|
expect(addTagIDs).toEqual(['tag3']) |
|
|
|
|
|
expect(removeTagIDs).toEqual(['tag1']) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should handle empty tag arrays correctly', () => { |
|
|
|
|
|
const currentValue: string[] = [] |
|
|
|
|
|
const selectedTagIDs = ['tag1'] |
|
|
|
|
|
|
|
|
|
|
|
const addTagIDs = selectedTagIDs.filter(v => !currentValue.includes(v)) |
|
|
|
|
|
const removeTagIDs = currentValue.filter(v => !selectedTagIDs.includes(v)) |
|
|
|
|
|
|
|
|
|
|
|
expect(addTagIDs).toEqual(['tag1']) |
|
|
|
|
|
expect(removeTagIDs).toEqual([]) |
|
|
|
|
|
expect(currentValue.length).toBe(0) // Verify empty array usage |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should handle removing all tags', () => { |
|
|
|
|
|
const currentValue = ['tag1', 'tag2'] |
|
|
|
|
|
const selectedTagIDs: string[] = [] |
|
|
|
|
|
|
|
|
|
|
|
const addTagIDs = selectedTagIDs.filter(v => !currentValue.includes(v)) |
|
|
|
|
|
const removeTagIDs = currentValue.filter(v => !selectedTagIDs.includes(v)) |
|
|
|
|
|
|
|
|
|
|
|
expect(addTagIDs).toEqual([]) |
|
|
|
|
|
expect(removeTagIDs).toEqual(['tag1', 'tag2']) |
|
|
|
|
|
expect(selectedTagIDs.length).toBe(0) // Verify empty array usage |
|
|
|
|
|
}) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
describe('Fallback Logic (from layout-main.tsx)', () => { |
|
|
|
|
|
it('should trigger fallback when tags are missing or empty', () => { |
|
|
|
|
|
const appDetailWithoutTags = { tags: [] } |
|
|
|
|
|
const appDetailWithTags = { tags: [{ id: 'tag1' }] } |
|
|
|
|
|
const appDetailWithUndefinedTags = { tags: undefined as any } |
|
|
|
|
|
|
|
|
|
|
|
// This simulates the condition in layout-main.tsx |
|
|
|
|
|
const shouldFallback1 = !appDetailWithoutTags.tags || appDetailWithoutTags.tags.length === 0 |
|
|
|
|
|
const shouldFallback2 = !appDetailWithTags.tags || appDetailWithTags.tags.length === 0 |
|
|
|
|
|
const shouldFallback3 = !appDetailWithUndefinedTags.tags || appDetailWithUndefinedTags.tags.length === 0 |
|
|
|
|
|
|
|
|
|
|
|
expect(shouldFallback1).toBe(true) // Empty array should trigger fallback |
|
|
|
|
|
expect(shouldFallback2).toBe(false) // Has tags, no fallback needed |
|
|
|
|
|
expect(shouldFallback3).toBe(true) // Undefined tags should trigger fallback |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should preserve tags when fallback succeeds', () => { |
|
|
|
|
|
const originalAppDetail = { tags: [] as any[] } |
|
|
|
|
|
const fallbackResult = { tags: [{ id: 'tag1', name: 'fallback-tag' }] } |
|
|
|
|
|
|
|
|
|
|
|
// This simulates the successful fallback in layout-main.tsx |
|
|
|
|
|
if (fallbackResult?.tags) |
|
|
|
|
|
originalAppDetail.tags = fallbackResult.tags |
|
|
|
|
|
|
|
|
|
|
|
expect(originalAppDetail.tags).toEqual(fallbackResult.tags) |
|
|
|
|
|
expect(originalAppDetail.tags.length).toBe(1) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should continue with empty tags when fallback fails', () => { |
|
|
|
|
|
const originalAppDetail: { tags: any[] } = { tags: [] } |
|
|
|
|
|
const fallbackResult: { tags?: any[] } | null = null |
|
|
|
|
|
|
|
|
|
|
|
// This simulates fallback failure in layout-main.tsx |
|
|
|
|
|
if (fallbackResult?.tags) |
|
|
|
|
|
originalAppDetail.tags = fallbackResult.tags |
|
|
|
|
|
|
|
|
|
|
|
expect(originalAppDetail.tags).toEqual([]) |
|
|
|
|
|
}) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
describe('TagSelector Auto-initialization Logic', () => { |
|
|
|
|
|
it('should trigger getTagList when tagList is empty', () => { |
|
|
|
|
|
const tagList: any[] = [] |
|
|
|
|
|
let getTagListCalled = false |
|
|
|
|
|
const getTagList = () => { |
|
|
|
|
|
getTagListCalled = true |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// This simulates the useEffect in TagSelector |
|
|
|
|
|
if (tagList.length === 0) |
|
|
|
|
|
getTagList() |
|
|
|
|
|
|
|
|
|
|
|
expect(getTagListCalled).toBe(true) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should not trigger getTagList when tagList has items', () => { |
|
|
|
|
|
const tagList = [{ id: 'tag1', name: 'existing-tag' }] |
|
|
|
|
|
let getTagListCalled = false |
|
|
|
|
|
const getTagList = () => { |
|
|
|
|
|
getTagListCalled = true |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// This simulates the useEffect in TagSelector |
|
|
|
|
|
if (tagList.length === 0) |
|
|
|
|
|
getTagList() |
|
|
|
|
|
|
|
|
|
|
|
expect(getTagListCalled).toBe(false) |
|
|
|
|
|
}) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
describe('State Initialization Patterns', () => { |
|
|
|
|
|
it('should maintain AppCard tag state pattern', () => { |
|
|
|
|
|
const app = { tags: [{ id: 'tag1', name: 'test' }] } |
|
|
|
|
|
|
|
|
|
|
|
// Original AppCard pattern: useState(app.tags) |
|
|
|
|
|
const initialTags = app.tags |
|
|
|
|
|
expect(Array.isArray(initialTags)).toBe(true) |
|
|
|
|
|
expect(initialTags.length).toBe(1) |
|
|
|
|
|
expect(initialTags).toBe(app.tags) // Reference equality for AppCard |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should maintain AppInfo tag state pattern', () => { |
|
|
|
|
|
const appDetail = { tags: [{ id: 'tag1', name: 'test' }] } |
|
|
|
|
|
|
|
|
|
|
|
// New AppInfo pattern: useState(appDetail?.tags || []) |
|
|
|
|
|
const initialTags = appDetail?.tags || [] |
|
|
|
|
|
expect(Array.isArray(initialTags)).toBe(true) |
|
|
|
|
|
expect(initialTags.length).toBe(1) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should handle undefined appDetail gracefully in AppInfo', () => { |
|
|
|
|
|
const appDetail = undefined |
|
|
|
|
|
|
|
|
|
|
|
// AppInfo pattern with undefined appDetail |
|
|
|
|
|
const initialTags = (appDetail as any)?.tags || [] |
|
|
|
|
|
expect(Array.isArray(initialTags)).toBe(true) |
|
|
|
|
|
expect(initialTags.length).toBe(0) |
|
|
|
|
|
}) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
describe('CSS Class and Layout Logic', () => { |
|
|
|
|
|
it('should apply correct minimum width condition', () => { |
|
|
|
|
|
const minWidth = 'true' |
|
|
|
|
|
|
|
|
|
|
|
// This tests the minWidth logic in TagSelector |
|
|
|
|
|
const shouldApplyMinWidth = minWidth && '!min-w-80' |
|
|
|
|
|
expect(shouldApplyMinWidth).toBe('!min-w-80') |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should not apply minimum width when not specified', () => { |
|
|
|
|
|
const minWidth = undefined |
|
|
|
|
|
|
|
|
|
|
|
const shouldApplyMinWidth = minWidth && '!min-w-80' |
|
|
|
|
|
expect(shouldApplyMinWidth).toBeFalsy() |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should handle overflow layout classes correctly', () => { |
|
|
|
|
|
// This tests the layout pattern from AppCard and new AppInfo |
|
|
|
|
|
const overflowLayoutClasses = { |
|
|
|
|
|
container: 'flex w-0 grow items-center', |
|
|
|
|
|
inner: 'w-full', |
|
|
|
|
|
truncate: 'truncate', |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
expect(overflowLayoutClasses.container).toContain('w-0 grow') |
|
|
|
|
|
expect(overflowLayoutClasses.inner).toContain('w-full') |
|
|
|
|
|
expect(overflowLayoutClasses.truncate).toBe('truncate') |
|
|
|
|
|
}) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
describe('fetchAppWithTags Service Logic', () => { |
|
|
|
|
|
it('should correctly find app by ID from app list', () => { |
|
|
|
|
|
const appList = [ |
|
|
|
|
|
{ id: 'app1', name: 'App 1', tags: [] }, |
|
|
|
|
|
{ id: 'test-app-id', name: 'Test App', tags: [{ id: 'tag1', name: 'test' }] }, |
|
|
|
|
|
{ id: 'app3', name: 'App 3', tags: [] }, |
|
|
|
|
|
] |
|
|
|
|
|
const targetAppId = 'test-app-id' |
|
|
|
|
|
|
|
|
|
|
|
// This simulates the logic in fetchAppWithTags |
|
|
|
|
|
const foundApp = appList.find(app => app.id === targetAppId) |
|
|
|
|
|
|
|
|
|
|
|
expect(foundApp).toBeDefined() |
|
|
|
|
|
expect(foundApp?.id).toBe('test-app-id') |
|
|
|
|
|
expect(foundApp?.tags.length).toBe(1) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should return null when app not found', () => { |
|
|
|
|
|
const appList = [ |
|
|
|
|
|
{ id: 'app1', name: 'App 1' }, |
|
|
|
|
|
{ id: 'app2', name: 'App 2' }, |
|
|
|
|
|
] |
|
|
|
|
|
const targetAppId = 'nonexistent-app' |
|
|
|
|
|
|
|
|
|
|
|
const foundApp = appList.find(app => app.id === targetAppId) || null |
|
|
|
|
|
|
|
|
|
|
|
expect(foundApp).toBeNull() |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should handle empty app list', () => { |
|
|
|
|
|
const appList: any[] = [] |
|
|
|
|
|
const targetAppId = 'any-app' |
|
|
|
|
|
|
|
|
|
|
|
const foundApp = appList.find(app => app.id === targetAppId) || null |
|
|
|
|
|
|
|
|
|
|
|
expect(foundApp).toBeNull() |
|
|
|
|
|
expect(appList.length).toBe(0) // Verify empty array usage |
|
|
|
|
|
}) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
describe('Data Structure Validation', () => { |
|
|
|
|
|
it('should maintain consistent tag data structure', () => { |
|
|
|
|
|
const tag = { |
|
|
|
|
|
id: 'tag1', |
|
|
|
|
|
name: 'test-tag', |
|
|
|
|
|
type: 'app', |
|
|
|
|
|
binding_count: 1, |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
expect(tag).toHaveProperty('id') |
|
|
|
|
|
expect(tag).toHaveProperty('name') |
|
|
|
|
|
expect(tag).toHaveProperty('type') |
|
|
|
|
|
expect(tag).toHaveProperty('binding_count') |
|
|
|
|
|
expect(tag.type).toBe('app') |
|
|
|
|
|
expect(typeof tag.binding_count).toBe('number') |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should handle tag arrays correctly', () => { |
|
|
|
|
|
const tags = [ |
|
|
|
|
|
{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: 1 }, |
|
|
|
|
|
{ id: 'tag2', name: 'Tag 2', type: 'app', binding_count: 0 }, |
|
|
|
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
expect(Array.isArray(tags)).toBe(true) |
|
|
|
|
|
expect(tags.length).toBe(2) |
|
|
|
|
|
expect(tags.every(tag => tag.type === 'app')).toBe(true) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should validate app data structure with tags', () => { |
|
|
|
|
|
const app = { |
|
|
|
|
|
id: 'test-app', |
|
|
|
|
|
name: 'Test App', |
|
|
|
|
|
tags: [ |
|
|
|
|
|
{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: 1 }, |
|
|
|
|
|
], |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
expect(app).toHaveProperty('id') |
|
|
|
|
|
expect(app).toHaveProperty('name') |
|
|
|
|
|
expect(app).toHaveProperty('tags') |
|
|
|
|
|
expect(Array.isArray(app.tags)).toBe(true) |
|
|
|
|
|
expect(app.tags.length).toBe(1) |
|
|
|
|
|
}) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
describe('Performance and Edge Cases', () => { |
|
|
|
|
|
it('should handle large tag arrays efficiently', () => { |
|
|
|
|
|
const largeTags = Array.from({ length: 100 }, (_, i) => `tag${i}`) |
|
|
|
|
|
const selectedTags = ['tag1', 'tag50', 'tag99'] |
|
|
|
|
|
|
|
|
|
|
|
// Performance test: filtering should be efficient |
|
|
|
|
|
const startTime = Date.now() |
|
|
|
|
|
const addTags = selectedTags.filter(tag => !largeTags.includes(tag)) |
|
|
|
|
|
const removeTags = largeTags.filter(tag => !selectedTags.includes(tag)) |
|
|
|
|
|
const endTime = Date.now() |
|
|
|
|
|
|
|
|
|
|
|
expect(endTime - startTime).toBeLessThan(10) // Should be very fast |
|
|
|
|
|
expect(addTags.length).toBe(0) // All selected tags exist |
|
|
|
|
|
expect(removeTags.length).toBe(97) // 100 - 3 = 97 tags to remove |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should handle malformed tag data gracefully', () => { |
|
|
|
|
|
const mixedData = [ |
|
|
|
|
|
{ id: 'valid1', name: 'Valid Tag', type: 'app', binding_count: 1 }, |
|
|
|
|
|
{ id: 'invalid1' }, // Missing required properties |
|
|
|
|
|
null, |
|
|
|
|
|
undefined, |
|
|
|
|
|
{ id: 'valid2', name: 'Another Valid', type: 'app', binding_count: 0 }, |
|
|
|
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
// Filter out invalid entries |
|
|
|
|
|
const validTags = mixedData.filter((tag): tag is { id: string; name: string; type: string; binding_count: number } => |
|
|
|
|
|
tag != null |
|
|
|
|
|
&& typeof tag === 'object' |
|
|
|
|
|
&& 'id' in tag |
|
|
|
|
|
&& 'name' in tag |
|
|
|
|
|
&& 'type' in tag |
|
|
|
|
|
&& 'binding_count' in tag |
|
|
|
|
|
&& typeof tag.binding_count === 'number', |
|
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
expect(validTags.length).toBe(2) |
|
|
|
|
|
expect(validTags.every(tag => tag.id && tag.name)).toBe(true) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should handle concurrent tag operations correctly', () => { |
|
|
|
|
|
const operations = [ |
|
|
|
|
|
{ type: 'add', tagIds: ['tag1', 'tag2'] }, |
|
|
|
|
|
{ type: 'remove', tagIds: ['tag3'] }, |
|
|
|
|
|
{ type: 'add', tagIds: ['tag4'] }, |
|
|
|
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
// Simulate processing operations |
|
|
|
|
|
const results = operations.map(op => ({ |
|
|
|
|
|
...op, |
|
|
|
|
|
processed: true, |
|
|
|
|
|
timestamp: Date.now(), |
|
|
|
|
|
})) |
|
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBe(3) |
|
|
|
|
|
expect(results.every(result => result.processed)).toBe(true) |
|
|
|
|
|
}) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
describe('Backward Compatibility Verification', () => { |
|
|
|
|
|
it('should not break existing AppCard behavior', () => { |
|
|
|
|
|
// Verify AppCard continues to work with original patterns |
|
|
|
|
|
const originalAppCardLogic = { |
|
|
|
|
|
initializeTags: (app: any) => app.tags, |
|
|
|
|
|
updateTags: (_currentTags: any[], newTags: any[]) => newTags, |
|
|
|
|
|
shouldRefresh: true, |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const app = { tags: [{ id: 'tag1', name: 'original' }] } |
|
|
|
|
|
const initializedTags = originalAppCardLogic.initializeTags(app) |
|
|
|
|
|
|
|
|
|
|
|
expect(initializedTags).toBe(app.tags) |
|
|
|
|
|
expect(originalAppCardLogic.shouldRefresh).toBe(true) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should ensure AppInfo follows AppCard patterns', () => { |
|
|
|
|
|
// Verify AppInfo uses compatible state management |
|
|
|
|
|
const appCardPattern = (app: any) => app.tags |
|
|
|
|
|
const appInfoPattern = (appDetail: any) => appDetail?.tags || [] |
|
|
|
|
|
|
|
|
|
|
|
const appWithTags = { tags: [{ id: 'tag1' }] } |
|
|
|
|
|
const appWithoutTags = { tags: [] } |
|
|
|
|
|
const undefinedApp = undefined |
|
|
|
|
|
|
|
|
|
|
|
expect(appCardPattern(appWithTags)).toEqual(appInfoPattern(appWithTags)) |
|
|
|
|
|
expect(appInfoPattern(appWithoutTags)).toEqual([]) |
|
|
|
|
|
expect(appInfoPattern(undefinedApp)).toEqual([]) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
it('should maintain consistent API parameters', () => { |
|
|
|
|
|
// Verify service layer maintains expected parameters |
|
|
|
|
|
const fetchAppListParams = { |
|
|
|
|
|
url: '/apps', |
|
|
|
|
|
params: { page: 1, limit: 100 }, |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const tagApiParams = { |
|
|
|
|
|
bindTag: (tagIDs: string[], targetID: string, type: string) => ({ tagIDs, targetID, type }), |
|
|
|
|
|
unBindTag: (tagID: string, targetID: string, type: string) => ({ tagID, targetID, type }), |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
expect(fetchAppListParams.url).toBe('/apps') |
|
|
|
|
|
expect(fetchAppListParams.params.limit).toBe(100) |
|
|
|
|
|
|
|
|
|
|
|
const bindResult = tagApiParams.bindTag(['tag1'], 'app1', 'app') |
|
|
|
|
|
expect(bindResult.tagIDs).toEqual(['tag1']) |
|
|
|
|
|
expect(bindResult.type).toBe('app') |
|
|
|
|
|
}) |
|
|
|
|
|
}) |
|
|
|
|
|
}) |