group-operations.test.ts
1 /** 2 * Tests for workspace group CRUD operations 3 * Following TDD: Write failing tests first, then implement 4 */ 5 6 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 7 import type { 8 WorkspaceGroup, 9 ParentWorkspace, 10 ChildWorkspace, 11 WorkspaceConfig, 12 } from '../../../src/lib/types'; 13 import { createDefaultConfig } from '../../../src/lib/types'; 14 15 // Helper to create valid metadata 16 const createMetadata = () => ({ 17 created: new Date().toISOString(), 18 lastModified: new Date().toISOString(), 19 lastAccessed: new Date().toISOString(), 20 switchCount: 0, 21 totalTimeActive: 0, 22 }); 23 24 // Helper to create a parent workspace with optional groups 25 function createParent( 26 id: string, 27 childIds: string[] = [], 28 groups?: WorkspaceGroup[] 29 ): ParentWorkspace { 30 return { 31 id, 32 name: `Parent ${id}`, 33 type: 'parent', 34 parentId: null, 35 isTranscendent: false, 36 openInContextSwitch: true, 37 children: childIds, 38 groups, 39 metadata: createMetadata(), 40 }; 41 } 42 43 // Helper to create a child workspace with optional groupId 44 function createChild( 45 id: string, 46 parentId: string, 47 groupId?: string | null 48 ): ChildWorkspace { 49 return { 50 id, 51 name: `Child ${id}`, 52 type: 'child', 53 parentId, 54 isTranscendent: false, 55 openInContextSwitch: true, 56 tabs: [], 57 groupId, 58 metadata: createMetadata(), 59 }; 60 } 61 62 // Mock storage module 63 const mockConfig: { current: WorkspaceConfig } = { 64 current: createDefaultConfig(), 65 }; 66 67 vi.mock('../../../src/lib/storage', () => ({ 68 storage: { 69 read: vi.fn(() => Promise.resolve(mockConfig.current)), 70 update: vi.fn(async (updater: (config: WorkspaceConfig) => WorkspaceConfig) => { 71 try { 72 mockConfig.current = updater(mockConfig.current); 73 return { success: true, data: mockConfig.current }; 74 } catch (error) { 75 return { success: false, error: (error as Error).message }; 76 } 77 }), 78 }, 79 })); 80 81 // Import after mock is set up 82 import { workspaceManager } from '../../../src/lib/workspace/manager'; 83 84 describe('Group CRUD operations', () => { 85 beforeEach(() => { 86 // Reset mock config before each test 87 mockConfig.current = createDefaultConfig(); 88 }); 89 90 afterEach(() => { 91 vi.clearAllMocks(); 92 }); 93 94 describe('createGroup', () => { 95 it('should add group to parent.groups array', async () => { 96 // Setup: Create a parent workspace 97 const parent = createParent('parent-1', []); 98 mockConfig.current.workspaces = [parent]; 99 100 // Execute 101 const group = await workspaceManager.createGroup('parent-1', 'New Group'); 102 103 // Verify 104 expect(group).toBeDefined(); 105 expect(group.name).toBe('New Group'); 106 expect(group.id).toBeDefined(); 107 108 const updatedParent = mockConfig.current.workspaces.find( 109 ws => ws.id === 'parent-1' 110 ) as ParentWorkspace; 111 expect(updatedParent.groups).toHaveLength(1); 112 expect(updatedParent.groups![0].name).toBe('New Group'); 113 }); 114 115 it('should generate unique ID for new group', async () => { 116 const parent = createParent('parent-1', [], [ 117 { id: 'existing-group', name: 'Existing' }, 118 ]); 119 mockConfig.current.workspaces = [parent]; 120 121 const group = await workspaceManager.createGroup('parent-1', 'New Group'); 122 123 expect(group.id).not.toBe('existing-group'); 124 expect(group.id.length).toBeGreaterThan(0); 125 }); 126 127 it('should fail if parent not found', async () => { 128 mockConfig.current.workspaces = []; 129 130 await expect( 131 workspaceManager.createGroup('non-existent', 'New Group') 132 ).rejects.toThrow('Parent workspace not found'); 133 }); 134 135 it('should fail if group name is empty', async () => { 136 const parent = createParent('parent-1', []); 137 mockConfig.current.workspaces = [parent]; 138 139 await expect( 140 workspaceManager.createGroup('parent-1', '') 141 ).rejects.toThrow('Group name cannot be empty'); 142 }); 143 144 it('should accept optional color', async () => { 145 const parent = createParent('parent-1', []); 146 mockConfig.current.workspaces = [parent]; 147 148 const group = await workspaceManager.createGroup('parent-1', 'Colored Group', { 149 color: '#ff5500', 150 }); 151 152 expect(group.color).toBe('#ff5500'); 153 }); 154 }); 155 156 describe('renameGroup', () => { 157 it('should update group name', async () => { 158 const parent = createParent('parent-1', [], [ 159 { id: 'group-1', name: 'Old Name' }, 160 ]); 161 mockConfig.current.workspaces = [parent]; 162 163 await workspaceManager.renameGroup('parent-1', 'group-1', 'New Name'); 164 165 const updatedParent = mockConfig.current.workspaces.find( 166 ws => ws.id === 'parent-1' 167 ) as ParentWorkspace; 168 expect(updatedParent.groups![0].name).toBe('New Name'); 169 }); 170 171 it('should fail if group not found', async () => { 172 const parent = createParent('parent-1', [], []); 173 mockConfig.current.workspaces = [parent]; 174 175 await expect( 176 workspaceManager.renameGroup('parent-1', 'non-existent', 'New Name') 177 ).rejects.toThrow('Group not found'); 178 }); 179 180 it('should fail if parent not found', async () => { 181 mockConfig.current.workspaces = []; 182 183 await expect( 184 workspaceManager.renameGroup('non-existent', 'group-1', 'New Name') 185 ).rejects.toThrow('Parent workspace not found'); 186 }); 187 }); 188 189 describe('deleteGroup', () => { 190 it('should remove group from parent.groups', async () => { 191 const parent = createParent('parent-1', ['child-1'], [ 192 { id: 'group-1', name: 'Group A' }, 193 { id: 'group-2', name: 'Group B' }, 194 ]); 195 const child = createChild('child-1', 'parent-1', 'group-1'); 196 mockConfig.current.workspaces = [parent, child]; 197 198 await workspaceManager.deleteGroup('parent-1', 'group-1'); 199 200 const updatedParent = mockConfig.current.workspaces.find( 201 ws => ws.id === 'parent-1' 202 ) as ParentWorkspace; 203 expect(updatedParent.groups).toHaveLength(1); 204 expect(updatedParent.groups![0].id).toBe('group-2'); 205 }); 206 207 it('should set groupId to null for affected children', async () => { 208 const parent = createParent('parent-1', ['child-1', 'child-2'], [ 209 { id: 'group-1', name: 'Group A' }, 210 ]); 211 const child1 = createChild('child-1', 'parent-1', 'group-1'); 212 const child2 = createChild('child-2', 'parent-1', 'group-1'); 213 mockConfig.current.workspaces = [parent, child1, child2]; 214 215 await workspaceManager.deleteGroup('parent-1', 'group-1'); 216 217 const updatedChild1 = mockConfig.current.workspaces.find( 218 ws => ws.id === 'child-1' 219 ) as ChildWorkspace; 220 const updatedChild2 = mockConfig.current.workspaces.find( 221 ws => ws.id === 'child-2' 222 ) as ChildWorkspace; 223 expect(updatedChild1.groupId).toBeNull(); 224 expect(updatedChild2.groupId).toBeNull(); 225 }); 226 227 it('should not affect children in other groups', async () => { 228 const parent = createParent('parent-1', ['child-1', 'child-2'], [ 229 { id: 'group-1', name: 'Group A' }, 230 { id: 'group-2', name: 'Group B' }, 231 ]); 232 const child1 = createChild('child-1', 'parent-1', 'group-1'); 233 const child2 = createChild('child-2', 'parent-1', 'group-2'); 234 mockConfig.current.workspaces = [parent, child1, child2]; 235 236 await workspaceManager.deleteGroup('parent-1', 'group-1'); 237 238 const updatedChild2 = mockConfig.current.workspaces.find( 239 ws => ws.id === 'child-2' 240 ) as ChildWorkspace; 241 expect(updatedChild2.groupId).toBe('group-2'); 242 }); 243 }); 244 245 describe('setChildGroup', () => { 246 it('should update child.groupId', async () => { 247 const parent = createParent('parent-1', ['child-1'], [ 248 { id: 'group-1', name: 'Group A' }, 249 ]); 250 const child = createChild('child-1', 'parent-1'); 251 mockConfig.current.workspaces = [parent, child]; 252 253 await workspaceManager.setChildGroup('child-1', 'group-1'); 254 255 const updatedChild = mockConfig.current.workspaces.find( 256 ws => ws.id === 'child-1' 257 ) as ChildWorkspace; 258 expect(updatedChild.groupId).toBe('group-1'); 259 }); 260 261 it('should validate groupId exists in parent', async () => { 262 const parent = createParent('parent-1', ['child-1'], [ 263 { id: 'group-1', name: 'Group A' }, 264 ]); 265 const child = createChild('child-1', 'parent-1'); 266 mockConfig.current.workspaces = [parent, child]; 267 268 await expect( 269 workspaceManager.setChildGroup('child-1', 'non-existent-group') 270 ).rejects.toThrow('Group not found in parent'); 271 }); 272 273 it('should allow setting groupId to null (ungroup)', async () => { 274 const parent = createParent('parent-1', ['child-1'], [ 275 { id: 'group-1', name: 'Group A' }, 276 ]); 277 const child = createChild('child-1', 'parent-1', 'group-1'); 278 mockConfig.current.workspaces = [parent, child]; 279 280 await workspaceManager.setChildGroup('child-1', null); 281 282 const updatedChild = mockConfig.current.workspaces.find( 283 ws => ws.id === 'child-1' 284 ) as ChildWorkspace; 285 expect(updatedChild.groupId).toBeNull(); 286 }); 287 288 it('should fail if child not found', async () => { 289 mockConfig.current.workspaces = []; 290 291 await expect( 292 workspaceManager.setChildGroup('non-existent', 'group-1') 293 ).rejects.toThrow('Child workspace not found'); 294 }); 295 296 it('should fail if workspace is not a child', async () => { 297 const parent = createParent('parent-1', []); 298 mockConfig.current.workspaces = [parent]; 299 300 await expect( 301 workspaceManager.setChildGroup('parent-1', null) 302 ).rejects.toThrow('Can only set group on child workspaces'); 303 }); 304 }); 305 306 describe('toggleGroupCollapsed', () => { 307 it('should toggle group.collapsed from false to true', async () => { 308 const parent = createParent('parent-1', [], [ 309 { id: 'group-1', name: 'Group A', collapsed: false }, 310 ]); 311 mockConfig.current.workspaces = [parent]; 312 313 await workspaceManager.toggleGroupCollapsed('parent-1', 'group-1'); 314 315 const updatedParent = mockConfig.current.workspaces.find( 316 ws => ws.id === 'parent-1' 317 ) as ParentWorkspace; 318 expect(updatedParent.groups![0].collapsed).toBe(true); 319 }); 320 321 it('should toggle group.collapsed from true to false', async () => { 322 const parent = createParent('parent-1', [], [ 323 { id: 'group-1', name: 'Group A', collapsed: true }, 324 ]); 325 mockConfig.current.workspaces = [parent]; 326 327 await workspaceManager.toggleGroupCollapsed('parent-1', 'group-1'); 328 329 const updatedParent = mockConfig.current.workspaces.find( 330 ws => ws.id === 'parent-1' 331 ) as ParentWorkspace; 332 expect(updatedParent.groups![0].collapsed).toBe(false); 333 }); 334 335 it('should toggle group.collapsed from undefined to true', async () => { 336 const parent = createParent('parent-1', [], [ 337 { id: 'group-1', name: 'Group A' }, // collapsed is undefined 338 ]); 339 mockConfig.current.workspaces = [parent]; 340 341 await workspaceManager.toggleGroupCollapsed('parent-1', 'group-1'); 342 343 const updatedParent = mockConfig.current.workspaces.find( 344 ws => ws.id === 'parent-1' 345 ) as ParentWorkspace; 346 expect(updatedParent.groups![0].collapsed).toBe(true); 347 }); 348 349 it('should fail if group not found', async () => { 350 const parent = createParent('parent-1', [], []); 351 mockConfig.current.workspaces = [parent]; 352 353 await expect( 354 workspaceManager.toggleGroupCollapsed('parent-1', 'non-existent') 355 ).rejects.toThrow('Group not found'); 356 }); 357 }); 358 359 describe('updateGroupColor', () => { 360 it('should update group color', async () => { 361 const parent = createParent('parent-1', [], [ 362 { id: 'group-1', name: 'Group A' }, 363 ]); 364 mockConfig.current.workspaces = [parent]; 365 366 await workspaceManager.updateGroupColor('parent-1', 'group-1', '#00ff00'); 367 368 const updatedParent = mockConfig.current.workspaces.find( 369 ws => ws.id === 'parent-1' 370 ) as ParentWorkspace; 371 expect(updatedParent.groups![0].color).toBe('#00ff00'); 372 }); 373 374 it('should allow clearing color with null', async () => { 375 const parent = createParent('parent-1', [], [ 376 { id: 'group-1', name: 'Group A', color: '#ff0000' }, 377 ]); 378 mockConfig.current.workspaces = [parent]; 379 380 await workspaceManager.updateGroupColor('parent-1', 'group-1', null); 381 382 const updatedParent = mockConfig.current.workspaces.find( 383 ws => ws.id === 'parent-1' 384 ) as ParentWorkspace; 385 expect(updatedParent.groups![0].color).toBeUndefined(); 386 }); 387 }); 388 });