/ tests / unit / workspace / group-operations.test.ts
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  });