argent-grid 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.github/workflows/pages.yml +68 -0
  2. package/AGENTS.md +179 -0
  3. package/README.md +222 -0
  4. package/demo-app/README.md +70 -0
  5. package/demo-app/angular.json +78 -0
  6. package/demo-app/e2e/benchmark.spec.ts +53 -0
  7. package/demo-app/e2e/demo-page.spec.ts +77 -0
  8. package/demo-app/e2e/grid-features.spec.ts +269 -0
  9. package/demo-app/package-lock.json +14023 -0
  10. package/demo-app/package.json +36 -0
  11. package/demo-app/playwright-test-menu.js +19 -0
  12. package/demo-app/playwright.config.ts +23 -0
  13. package/demo-app/src/app/app.component.ts +10 -0
  14. package/demo-app/src/app/app.config.ts +13 -0
  15. package/demo-app/src/app/app.routes.ts +7 -0
  16. package/demo-app/src/app/demo-page/demo-page.component.css +313 -0
  17. package/demo-app/src/app/demo-page/demo-page.component.html +124 -0
  18. package/demo-app/src/app/demo-page/demo-page.component.ts +366 -0
  19. package/demo-app/src/index.html +19 -0
  20. package/demo-app/src/main.ts +6 -0
  21. package/demo-app/tsconfig.json +31 -0
  22. package/ng-package.json +8 -0
  23. package/package.json +60 -0
  24. package/plan.md +131 -0
  25. package/setup-vitest.ts +18 -0
  26. package/src/lib/argent-grid.module.ts +21 -0
  27. package/src/lib/components/argent-grid.component.css +483 -0
  28. package/src/lib/components/argent-grid.component.html +320 -0
  29. package/src/lib/components/argent-grid.component.spec.ts +189 -0
  30. package/src/lib/components/argent-grid.component.ts +1188 -0
  31. package/src/lib/directives/ag-grid-compatibility.directive.ts +92 -0
  32. package/src/lib/rendering/canvas-renderer.ts +962 -0
  33. package/src/lib/rendering/render/blit.spec.ts +453 -0
  34. package/src/lib/rendering/render/blit.ts +393 -0
  35. package/src/lib/rendering/render/cells.ts +369 -0
  36. package/src/lib/rendering/render/index.ts +105 -0
  37. package/src/lib/rendering/render/lines.ts +363 -0
  38. package/src/lib/rendering/render/theme.spec.ts +282 -0
  39. package/src/lib/rendering/render/theme.ts +201 -0
  40. package/src/lib/rendering/render/types.ts +279 -0
  41. package/src/lib/rendering/render/walk.spec.ts +360 -0
  42. package/src/lib/rendering/render/walk.ts +360 -0
  43. package/src/lib/rendering/utils/damage-tracker.spec.ts +444 -0
  44. package/src/lib/rendering/utils/damage-tracker.ts +423 -0
  45. package/src/lib/rendering/utils/index.ts +7 -0
  46. package/src/lib/services/grid.service.spec.ts +1039 -0
  47. package/src/lib/services/grid.service.ts +1284 -0
  48. package/src/lib/types/ag-grid-types.ts +970 -0
  49. package/src/public-api.ts +22 -0
  50. package/tsconfig.json +32 -0
  51. package/tsconfig.lib.json +11 -0
  52. package/tsconfig.spec.json +8 -0
  53. package/vitest.config.ts +55 -0
@@ -0,0 +1,1039 @@
1
+ import { TestBed } from '@angular/core/testing';
2
+ import { provideExperimentalZonelessChangeDetection } from '@angular/core';
3
+ import { GridService } from './grid.service';
4
+ import { GridApi, ColDef, FilterModel, IRowNode } from '../types/ag-grid-types';
5
+
6
+ interface TestData {
7
+ id: number;
8
+ name: string;
9
+ age: number;
10
+ email: string;
11
+ }
12
+
13
+ describe('GridService', () => {
14
+ let service: GridService<TestData>;
15
+ let api: GridApi<TestData>;
16
+
17
+ const testColumnDefs: (ColDef<TestData>)[] = [
18
+ { colId: 'id', field: 'id', headerName: 'ID', width: 100 },
19
+ { colId: 'name', field: 'name', headerName: 'Name', width: 150 },
20
+ { colId: 'age', field: 'age', headerName: 'Age', width: 80, sortable: true },
21
+ { colId: 'email', field: 'email', headerName: 'Email', width: 200 }
22
+ ];
23
+
24
+ const testRowData: TestData[] = [
25
+ { id: 1, name: 'John Doe', age: 30, email: 'john@example.com' },
26
+ { id: 2, name: 'Jane Smith', age: 25, email: 'jane@example.com' },
27
+ { id: 3, name: 'Bob Johnson', age: 35, email: 'bob@example.com' }
28
+ ];
29
+
30
+ beforeEach(() => {
31
+ TestBed.configureTestingModule({
32
+ providers: [
33
+ GridService,
34
+ provideExperimentalZonelessChangeDetection()
35
+ ]
36
+ });
37
+ service = TestBed.inject(GridService);
38
+ api = service.createApi(testColumnDefs, [...testRowData]);
39
+ });
40
+
41
+ it('should be created', () => {
42
+ expect(service).toBeTruthy();
43
+ });
44
+
45
+ it('should initialize with correct row data', () => {
46
+ const data = api.getRowData();
47
+ expect(data).toEqual(testRowData);
48
+ expect(data.length).toBe(3);
49
+ });
50
+
51
+ it('should initialize columns correctly', () => {
52
+ const columns = api.getAllColumns();
53
+ expect(columns.length).toBe(4);
54
+ expect(columns[0].colId).toBe('id');
55
+ expect(columns[0].headerName).toBe('ID');
56
+ });
57
+
58
+ it('should get row node by id', () => {
59
+ const node = api.getRowNode('1');
60
+ expect(node).toBeTruthy();
61
+ expect(node?.data.id).toBe(1);
62
+ });
63
+
64
+ it('should handle row selection', () => {
65
+ const selectionApi = service.createApi(testColumnDefs, [...testRowData]);
66
+ selectionApi.selectAll();
67
+ expect(selectionApi.getSelectedRows().length).toBe(3);
68
+
69
+ selectionApi.deselectAll();
70
+ expect(selectionApi.getSelectedRows().length).toBe(0);
71
+ });
72
+
73
+ it('should apply sorting', () => {
74
+ const sortApi = service.createApi(testColumnDefs, [...testRowData]);
75
+ // Sort by age descending
76
+ sortApi.setSortModel([{ colId: 'age', sort: 'desc' }]);
77
+
78
+ const sortedData = sortApi.getRowData();
79
+ expect(sortedData[0].age).toBe(35);
80
+ expect(sortedData[2].age).toBe(25);
81
+ });
82
+
83
+ it('should apply sorting ascending', () => {
84
+ const sortApi = service.createApi(testColumnDefs, [...testRowData]);
85
+ // Sort by age ascending
86
+ sortApi.setSortModel([{ colId: 'age', sort: 'asc' }]);
87
+
88
+ const sortedData = sortApi.getRowData();
89
+ expect(sortedData[0].age).toBe(25);
90
+ expect(sortedData[2].age).toBe(35);
91
+ });
92
+
93
+ it('should handle transaction - add rows and respect sorting', () => {
94
+ const sortApi = service.createApi(testColumnDefs, [...testRowData]);
95
+ sortApi.setSortModel([{ colId: 'name', sort: 'asc' }]);
96
+
97
+ // Initial alpha: Bob Johnson (35), Jane Smith (25), John Doe (30)
98
+ expect(sortApi.getDisplayedRowAtIndex(0)?.data.name).toBe('Bob Johnson');
99
+
100
+ sortApi.applyTransaction({
101
+ add: [{ id: 4, name: 'Alice', age: 28, email: 'alice@example.com' }]
102
+ });
103
+
104
+ // Alice should now be first
105
+ expect(sortApi.getDisplayedRowAtIndex(0)?.data.name).toBe('Alice');
106
+ expect(sortApi.getDisplayedRowCount()).toBe(4);
107
+ });
108
+
109
+ it('should handle transaction - update rows and respect filtering', () => {
110
+ const filterApi = service.createApi(testColumnDefs, [...testRowData]);
111
+ filterApi.setFilterModel({
112
+ age: { filterType: 'number', type: 'greaterThan', filter: 30 }
113
+ });
114
+
115
+ expect(filterApi.getDisplayedRowCount()).toBe(1); // Only Bob (35)
116
+
117
+ // Update Jane (25) to be 40
118
+ filterApi.applyTransaction({
119
+ update: [{ id: 2, name: 'Jane Smith', age: 40, email: 'jane@example.com' }]
120
+ });
121
+
122
+ expect(filterApi.getDisplayedRowCount()).toBe(2); // Bob and Jane
123
+ });
124
+
125
+ it('should handle transaction - remove rows', () => {
126
+ const initialCount = api.getDisplayedRowCount();
127
+ const firstNode = api.getDisplayedRowAtIndex(0);
128
+ if (!firstNode || !firstNode.id) return;
129
+
130
+ const removeData: TestData = { id: firstNode.data.id, name: firstNode.data.name, age: firstNode.data.age, email: firstNode.data.email };
131
+ const result = api.applyTransaction({
132
+ remove: [removeData]
133
+ });
134
+
135
+ expect(result?.remove.length).toBe(1);
136
+ expect(api.getDisplayedRowCount()).toBe(initialCount - 1);
137
+ });
138
+
139
+ it('should get and set filter model', () => {
140
+ const filterModel: FilterModel = {
141
+ name: { filterType: 'text', type: 'contains', filter: 'John' }
142
+ };
143
+
144
+ api.setFilterModel(filterModel);
145
+ expect(api.getFilterModel()).toEqual(filterModel);
146
+ expect(api.isFilterPresent()).toBe(true);
147
+ });
148
+
149
+ it('should apply text filter - contains', () => {
150
+ const filterApi = service.createApi(testColumnDefs, [...testRowData]);
151
+ filterApi.setFilterModel({
152
+ name: { filterType: 'text', type: 'contains', filter: 'John' }
153
+ });
154
+
155
+ const data = filterApi.getRowData();
156
+ // Filter should match 'John Doe' and 'Bob Johnson'
157
+ expect(data.length).toBeLessThan(3);
158
+ });
159
+
160
+ it('should apply text filter - starts with', () => {
161
+ const filterApi = service.createApi(testColumnDefs, [...testRowData]);
162
+ filterApi.setFilterModel({
163
+ name: { filterType: 'text', type: 'startsWith', filter: 'J' }
164
+ });
165
+
166
+ const data = filterApi.getRowData();
167
+ // Should match 'John Doe' and 'Jane Smith'
168
+ data.forEach(row => {
169
+ expect(row.name.startsWith('J')).toBe(true);
170
+ });
171
+ });
172
+
173
+ it('should apply text filter - ends with', () => {
174
+ const filterApi = service.createApi(testColumnDefs, [...testRowData]);
175
+ filterApi.setFilterModel({
176
+ name: { filterType: 'text', type: 'endsWith', filter: 'e' }
177
+ });
178
+
179
+ const data = filterApi.getRowData();
180
+ data.forEach(row => {
181
+ expect(row.name.endsWith('e')).toBe(true);
182
+ });
183
+ });
184
+
185
+ it('should apply text filter - equals', () => {
186
+ const filterApi = service.createApi(testColumnDefs, [...testRowData]);
187
+ filterApi.setFilterModel({
188
+ name: { filterType: 'text', type: 'equals', filter: 'John Doe' }
189
+ });
190
+
191
+ const data = filterApi.getRowData();
192
+ expect(data.length).toBe(1);
193
+ expect(data[0].name).toBe('John Doe');
194
+ });
195
+
196
+ it('should apply number filter - greater than', () => {
197
+ const filterApi = service.createApi(testColumnDefs, [...testRowData]);
198
+ filterApi.setFilterModel({
199
+ age: { filterType: 'number', type: 'greaterThan', filter: 28 }
200
+ });
201
+
202
+ const data = filterApi.getRowData();
203
+ data.forEach(row => {
204
+ expect(row.age).toBeGreaterThan(28);
205
+ });
206
+ });
207
+
208
+ it('should apply number filter - less than', () => {
209
+ const filterApi = service.createApi(testColumnDefs, [...testRowData]);
210
+ filterApi.setFilterModel({
211
+ age: { filterType: 'number', type: 'lessThan', filter: 32 }
212
+ });
213
+
214
+ const data = filterApi.getRowData();
215
+ data.forEach(row => {
216
+ expect(row.age).toBeLessThan(32);
217
+ });
218
+ });
219
+
220
+ it('should apply number filter - in range', () => {
221
+ const filterApi = service.createApi(testColumnDefs, [...testRowData]);
222
+ filterApi.setFilterModel({
223
+ age: { filterType: 'number', type: 'inRange', filter: 26, filterTo: 34 }
224
+ });
225
+
226
+ const data = filterApi.getRowData();
227
+ data.forEach(row => {
228
+ expect(row.age).toBeGreaterThanOrEqual(26);
229
+ expect(row.age).toBeLessThanOrEqual(34);
230
+ });
231
+ });
232
+
233
+ it('should apply date filter', () => {
234
+ const dateData: any[] = [
235
+ { id: 1, name: 'Event 1', date: '2024-01-15' },
236
+ { id: 2, name: 'Event 2', date: '2024-06-20' },
237
+ { id: 3, name: 'Event 3', date: '2024-12-01' }
238
+ ];
239
+ const dateColumnDefs: any[] = [
240
+ { colId: 'id', field: 'id', headerName: 'ID' },
241
+ { colId: 'name', field: 'name', headerName: 'Name' },
242
+ { colId: 'date', field: 'date', headerName: 'Date', filter: 'agDateColumnFilter' }
243
+ ];
244
+
245
+ const filterApi = service.createApi(dateColumnDefs, dateData);
246
+ filterApi.setFilterModel({
247
+ date: { filterType: 'date', type: 'greaterThan', filter: '2024-03-01' }
248
+ });
249
+
250
+ const data = filterApi.getRowData();
251
+ // Should match events after March 2024
252
+ expect(data.length).toBe(2);
253
+ });
254
+
255
+ it('should clear filter when model is empty', () => {
256
+ const filterApi = service.createApi(testColumnDefs, [...testRowData]);
257
+ filterApi.setFilterModel({
258
+ name: { filterType: 'text', type: 'contains', filter: 'John' }
259
+ });
260
+ expect(api.isFilterPresent()).toBe(true);
261
+
262
+ filterApi.setFilterModel({});
263
+ expect(filterApi.isFilterPresent()).toBe(false);
264
+ });
265
+
266
+ it('should combine multiple filters (AND logic)', () => {
267
+ const filterApi = service.createApi(testColumnDefs, [...testRowData]);
268
+ filterApi.setFilterModel({
269
+ name: { filterType: 'text', type: 'startsWith', filter: 'J' },
270
+ age: { filterType: 'number', type: 'lessThan', filter: 30 }
271
+ });
272
+
273
+ const data = filterApi.getRowData();
274
+ // Should match 'Jane Smith' (starts with J and age < 30)
275
+ data.forEach(row => {
276
+ expect(row.name.startsWith('J')).toBe(true);
277
+ expect(row.age).toBeLessThan(30);
278
+ });
279
+ });
280
+
281
+ // Row Grouping Tests
282
+ it('should group rows by column', () => {
283
+ const groupData: any[] = [
284
+ { id: 1, name: 'John', department: 'Engineering', salary: 80000 },
285
+ { id: 2, name: 'Jane', department: 'Engineering', salary: 90000 },
286
+ { id: 3, name: 'Bob', department: 'Sales', salary: 70000 },
287
+ { id: 4, name: 'Alice', department: 'Sales', salary: 75000 }
288
+ ];
289
+ const groupColumnDefs: ColDef[] = [
290
+ { colId: 'name', field: 'name', headerName: 'Name' },
291
+ { colId: 'department', field: 'department', headerName: 'Department', rowGroup: true },
292
+ { colId: 'salary', field: 'salary', headerName: 'Salary' }
293
+ ];
294
+
295
+ const groupApi = service.createApi(groupColumnDefs, groupData);
296
+
297
+ // With groups collapsed, should show 2 group rows
298
+ const displayedCount = groupApi.getDisplayedRowCount();
299
+ expect(displayedCount).toBe(2); // 2 groups (Engineering, Sales)
300
+ });
301
+
302
+ it('should expand and collapse groups', () => {
303
+ const groupData: any[] = [
304
+ { id: 1, name: 'John', department: 'Engineering' },
305
+ { id: 2, name: 'Jane', department: 'Engineering' },
306
+ { id: 3, name: 'Bob', department: 'Sales' }
307
+ ];
308
+ const groupColumnDefs: ColDef[] = [
309
+ { colId: 'name', field: 'name', headerName: 'Name' },
310
+ { colId: 'department', field: 'department', headerName: 'Department', rowGroup: true }
311
+ ];
312
+
313
+ const groupApi = service.createApi(groupColumnDefs, groupData);
314
+
315
+ // Initially groups are collapsed
316
+ let displayedCount = groupApi.getDisplayedRowCount();
317
+ expect(displayedCount).toBe(2); // 2 groups
318
+ });
319
+
320
+ it('should calculate group aggregations', () => {
321
+ const groupData: any[] = [
322
+ { id: 1, name: 'John', department: 'Engineering', salary: 80000 },
323
+ { id: 2, name: 'Jane', department: 'Engineering', salary: 90000 },
324
+ { id: 3, name: 'Bob', department: 'Sales', salary: 70000 }
325
+ ];
326
+ const groupColumnDefs: ColDef[] = [
327
+ { colId: 'name', field: 'name', headerName: 'Name' },
328
+ { colId: 'department', field: 'department', headerName: 'Department', rowGroup: true },
329
+ { colId: 'salary', field: 'salary', headerName: 'Salary', aggFunc: 'sum' }
330
+ ];
331
+
332
+ const groupApi = service.createApi(groupColumnDefs, groupData);
333
+
334
+ // Verify grouping works
335
+ const displayedCount = groupApi.getDisplayedRowCount();
336
+ expect(displayedCount).toBeGreaterThanOrEqual(2); // At least 2 groups
337
+ });
338
+
339
+ it('should support multiple row group columns', () => {
340
+ const groupData: any[] = [
341
+ { id: 1, name: 'John', department: 'Engineering', level: 'Senior' },
342
+ { id: 2, name: 'Jane', department: 'Engineering', level: 'Junior' },
343
+ { id: 3, name: 'Bob', department: 'Sales', level: 'Senior' }
344
+ ];
345
+ const groupColumnDefs: ColDef[] = [
346
+ { colId: 'name', field: 'name', headerName: 'Name' },
347
+ { colId: 'department', field: 'department', headerName: 'Department', rowGroup: true },
348
+ { colId: 'level', field: 'level', headerName: 'Level', rowGroup: true }
349
+ ];
350
+
351
+ const groupApi = service.createApi(groupColumnDefs, groupData);
352
+
353
+ // Should have hierarchical groups (Engineering/Senior, Engineering/Junior, Sales/Senior)
354
+ const displayedCount = groupApi.getDisplayedRowCount();
355
+ expect(displayedCount).toBe(2); // Engineering and Sales top level
356
+ });
357
+
358
+ it('should get grid state', () => {
359
+ const state = api.getState();
360
+ expect(state.sort).toBeDefined();
361
+ expect(state.columnOrder).toBeDefined();
362
+ });
363
+
364
+ // Cell Editing Tests
365
+ it('should update cell value on edit', () => {
366
+ const editApi = service.createApi(testColumnDefs, [...testRowData]);
367
+ const firstNode = editApi.getDisplayedRowAtIndex(0);
368
+ if (!firstNode) return;
369
+
370
+ const originalName = firstNode.data.name;
371
+
372
+ // Simulate cell edit via transaction
373
+ const newValue = 'Updated Name';
374
+ editApi.applyTransaction({
375
+ update: [{ ...firstNode.data, name: newValue }]
376
+ });
377
+
378
+ const updatedNode = editApi.getDisplayedRowAtIndex(0);
379
+ expect(updatedNode?.data.name).toBe(newValue);
380
+ expect(updatedNode?.data.name).not.toBe(originalName);
381
+ });
382
+
383
+ it('should support read-only cells', () => {
384
+ const readOnlyColumnDefs: ColDef[] = [
385
+ { colId: 'id', field: 'id', headerName: 'ID', editable: false },
386
+ { colId: 'name', field: 'name', headerName: 'Name', editable: true }
387
+ ];
388
+
389
+ expect(readOnlyColumnDefs[0].editable).toBe(false);
390
+ expect(readOnlyColumnDefs[1].editable).toBe(true);
391
+ });
392
+
393
+ it('should support valueParser on column', () => {
394
+ const parserColumnDefs: ColDef[] = [
395
+ { colId: 'id', field: 'id', headerName: 'ID' },
396
+ { colId: 'value', field: 'value', headerName: 'Value',
397
+ valueParser: (params: any) => Number(params.newValue) }
398
+ ];
399
+
400
+ expect(parserColumnDefs[1].valueParser).toBeDefined();
401
+ });
402
+
403
+ it('should support valueSetter on column', () => {
404
+ const setterColumnDefs: ColDef[] = [
405
+ { colId: 'id', field: 'id', headerName: 'ID' },
406
+ { colId: 'name', field: 'name', headerName: 'Name',
407
+ valueSetter: (params: any) => {
408
+ params.data.name = params.newValue.toUpperCase();
409
+ return true;
410
+ }}
411
+ ];
412
+
413
+ expect(setterColumnDefs[1].valueSetter).toBeDefined();
414
+ });
415
+
416
+ // Column Pinning Tests
417
+ it('should pin column to left', () => {
418
+ const pinColumnDefs: ColDef[] = [
419
+ { colId: 'id', field: 'id', headerName: 'ID', pinned: 'left' },
420
+ { colId: 'name', field: 'name', headerName: 'Name' },
421
+ { colId: 'value', field: 'value', headerName: 'Value' }
422
+ ];
423
+
424
+ const pinApi = service.createApi(pinColumnDefs, [...testRowData]);
425
+ const columns = pinApi.getAllColumns();
426
+
427
+ const pinnedCol = columns.find(c => c.pinned === 'left');
428
+ expect(pinnedCol).toBeDefined();
429
+ expect(pinnedCol?.colId).toBe('id');
430
+ });
431
+
432
+ it('should pin column to right', () => {
433
+ const pinColumnDefs: ColDef[] = [
434
+ { colId: 'id', field: 'id', headerName: 'ID' },
435
+ { colId: 'name', field: 'name', headerName: 'Name' },
436
+ { colId: 'value', field: 'value', headerName: 'Value', pinned: 'right' }
437
+ ];
438
+
439
+ const pinApi = service.createApi(pinColumnDefs, [...testRowData]);
440
+ const columns = pinApi.getAllColumns();
441
+
442
+ const pinnedCol = columns.find(c => c.pinned === 'right');
443
+ expect(pinnedCol).toBeDefined();
444
+ expect(pinnedCol?.colId).toBe('value');
445
+ });
446
+
447
+ it('should return pinned columns in getColumnPinningState', () => {
448
+ const pinColumnDefs: ColDef[] = [
449
+ { colId: 'id', field: 'id', headerName: 'ID', pinned: 'left' },
450
+ { colId: 'name', field: 'name', headerName: 'Name' },
451
+ { colId: 'value', field: 'value', headerName: 'Value', pinned: 'right' }
452
+ ];
453
+
454
+ const pinApi = service.createApi(pinColumnDefs, [...testRowData]);
455
+ const state = pinApi.getState();
456
+
457
+ expect(state.columnPinning).toBeDefined();
458
+ expect(state.columnPinning?.left).toContain('id');
459
+ expect(state.columnPinning?.right).toContain('value');
460
+ });
461
+
462
+ // Row Pinning Tests
463
+ it('should pin row to top', () => {
464
+ const rowData: any[] = [
465
+ { id: 1, name: 'Row 1' },
466
+ { id: 2, name: 'Row 2', pinned: 'top' },
467
+ { id: 3, name: 'Row 3' }
468
+ ];
469
+
470
+ const pinApi = service.createApi(testColumnDefs.slice(0, 2), rowData);
471
+ const displayedCount = pinApi.getDisplayedRowCount();
472
+
473
+ // Should have 3 rows total
474
+ expect(displayedCount).toBe(3);
475
+
476
+ // First row should be the pinned top one
477
+ const firstRow = pinApi.getDisplayedRowAtIndex(0);
478
+ expect(firstRow?.rowPinned).toBe('top');
479
+ });
480
+
481
+ it('should pin row to bottom', () => {
482
+ const rowData: any[] = [
483
+ { id: 1, name: 'Row 1' },
484
+ { id: 2, name: 'Row 2', pinned: 'bottom' },
485
+ { id: 3, name: 'Row 3' }
486
+ ];
487
+
488
+ const pinApi = service.createApi(testColumnDefs.slice(0, 2), rowData);
489
+
490
+ // Last row should be the pinned bottom one
491
+ const lastRowIndex = pinApi.getDisplayedRowCount() - 1;
492
+ const lastRow = pinApi.getDisplayedRowAtIndex(lastRowIndex);
493
+ expect(lastRow?.rowPinned).toBe('bottom');
494
+ });
495
+
496
+ it('should order pinned rows correctly', () => {
497
+ const rowData: any[] = [
498
+ { id: 1, name: 'Normal 1' },
499
+ { id: 2, name: 'Top 1', pinned: 'top' },
500
+ { id: 3, name: 'Normal 2' },
501
+ { id: 4, name: 'Bottom 1', pinned: 'bottom' },
502
+ { id: 5, name: 'Top 2', pinned: 'top' }
503
+ ];
504
+
505
+ const pinApi = service.createApi(testColumnDefs.slice(0, 2), rowData);
506
+
507
+ // Pinned top rows should come first
508
+ expect(pinApi.getDisplayedRowAtIndex(0)?.rowPinned).toBe('top');
509
+ expect(pinApi.getDisplayedRowAtIndex(1)?.rowPinned).toBe('top');
510
+
511
+ // Normal rows in middle
512
+ expect(pinApi.getDisplayedRowAtIndex(2)?.rowPinned).toBe(false);
513
+ expect(pinApi.getDisplayedRowAtIndex(3)?.rowPinned).toBe(false);
514
+
515
+ // Pinned bottom rows at end
516
+ expect(pinApi.getDisplayedRowAtIndex(4)?.rowPinned).toBe('bottom');
517
+ });
518
+
519
+ it('should get displayed row count', () => {
520
+ const freshApi = service.createApi(testColumnDefs, [...testRowData]);
521
+ expect(freshApi.getDisplayedRowCount()).toBe(3);
522
+ });
523
+
524
+ // Selection API Tests
525
+ it('should select single row', () => {
526
+ const selectApi = service.createApi(testColumnDefs, [...testRowData]);
527
+ const firstRow = selectApi.getDisplayedRowAtIndex(0);
528
+ if (!firstRow) return;
529
+
530
+ firstRow.selected = true;
531
+ const selected = selectApi.getSelectedRows();
532
+
533
+ expect(selected.length).toBe(1);
534
+ expect(selected[0].data.id).toBe(1);
535
+ });
536
+
537
+ it('should select multiple rows with Ctrl key', () => {
538
+ const selectApi = service.createApi(testColumnDefs, [...testRowData]);
539
+
540
+ // Select first row
541
+ const firstRow = selectApi.getDisplayedRowAtIndex(0);
542
+ if (!firstRow) return;
543
+ firstRow.selected = true;
544
+
545
+ // Ctrl+click to select third row (multi-select)
546
+ const thirdRow = selectApi.getDisplayedRowAtIndex(2);
547
+ if (!thirdRow) return;
548
+ thirdRow.selected = true;
549
+
550
+ const selected = selectApi.getSelectedRows();
551
+ expect(selected.length).toBe(2);
552
+ });
553
+
554
+ it('should select all rows', () => {
555
+ const selectApi = service.createApi(testColumnDefs, [...testRowData]);
556
+ selectApi.selectAll();
557
+
558
+ const selected = selectApi.getSelectedRows();
559
+ expect(selected.length).toBe(3);
560
+ });
561
+
562
+ it('should deselect all rows', () => {
563
+ const selectApi = service.createApi(testColumnDefs, [...testRowData]);
564
+ selectApi.selectAll();
565
+ expect(selectApi.getSelectedRows().length).toBe(3);
566
+
567
+ selectApi.deselectAll();
568
+ expect(selectApi.getSelectedRows().length).toBe(0);
569
+ });
570
+
571
+ it('should toggle row selection', () => {
572
+ const selectApi = service.createApi(testColumnDefs, [...testRowData]);
573
+ const row = selectApi.getDisplayedRowAtIndex(0);
574
+ if (!row) return;
575
+
576
+ row.selected = true;
577
+ expect(selectApi.getSelectedRows().length).toBe(1);
578
+
579
+ row.selected = false;
580
+ expect(selectApi.getSelectedRows().length).toBe(0);
581
+ });
582
+
583
+ it('should get selected row count', () => {
584
+ const selectApi = service.createApi(testColumnDefs, [...testRowData]);
585
+ selectApi.selectAll();
586
+
587
+ const selectedCount = selectApi.getSelectedRows().length;
588
+ expect(selectedCount).toBe(3);
589
+ });
590
+
591
+ it('should support row selection with checkbox', () => {
592
+ const selectionColumnDefs: ColDef[] = [
593
+ { colId: 'select', headerName: '', checkboxSelection: true, width: 50 },
594
+ { colId: 'id', field: 'id', headerName: 'ID' },
595
+ { colId: 'name', field: 'name', headerName: 'Name' }
596
+ ];
597
+
598
+ expect(selectionColumnDefs[0].checkboxSelection).toBe(true);
599
+ });
600
+
601
+ // Aggregation Tests
602
+ it('should calculate sum aggregation', () => {
603
+ const aggData: any[] = [
604
+ { id: 1, name: 'Item 1', value: 100 },
605
+ { id: 2, name: 'Item 2', value: 200 },
606
+ { id: 3, name: 'Item 3', value: 300 }
607
+ ];
608
+ const aggColumnDefs: ColDef[] = [
609
+ { colId: 'id', field: 'id', headerName: 'ID' },
610
+ { colId: 'name', field: 'name', headerName: 'Name' },
611
+ { colId: 'value', field: 'value', headerName: 'Value', aggFunc: 'sum' }
612
+ ];
613
+
614
+ service.createApi(aggColumnDefs, aggData);
615
+ const agg = service.calculateColumnAggregations(aggData);
616
+ expect(agg['value']).toBe(600);
617
+ });
618
+
619
+ it('should calculate average aggregation', () => {
620
+ const aggData: any[] = [
621
+ { id: 1, name: 'Item 1', value: 100 },
622
+ { id: 2, name: 'Item 2', value: 200 },
623
+ { id: 3, name: 'Item 3', value: 300 }
624
+ ];
625
+ const aggColumnDefs: ColDef[] = [
626
+ { colId: 'id', field: 'id', headerName: 'ID' },
627
+ { colId: 'value', field: 'value', headerName: 'Value', aggFunc: 'avg' }
628
+ ];
629
+
630
+ service.createApi(aggColumnDefs, aggData);
631
+ const agg = service.calculateColumnAggregations(aggData);
632
+ expect(agg['value']).toBe(200);
633
+ });
634
+
635
+ it('should calculate min/max aggregation', () => {
636
+ const aggData: any[] = [
637
+ { id: 1, name: 'Item 1', value: 100 },
638
+ { id: 2, name: 'Item 2', value: 50 },
639
+ { id: 3, name: 'Item 3', value: 300 }
640
+ ];
641
+ const aggColumnDefs: ColDef[] = [
642
+ { colId: 'value', field: 'value', headerName: 'Value', aggFunc: 'min' }
643
+ ];
644
+
645
+ service.createApi(aggColumnDefs, aggData);
646
+ const agg = service.calculateColumnAggregations(aggData);
647
+ expect(agg['value']).toBe(50);
648
+ });
649
+
650
+ it('should calculate max aggregation', () => {
651
+ const aggData: any[] = [
652
+ { id: 1, name: 'Item 1', value: 100 },
653
+ { id: 2, name: 'Item 2', value: 50 },
654
+ { id: 3, name: 'Item 3', value: 300 }
655
+ ];
656
+ const aggColumnDefs: ColDef[] = [
657
+ { colId: 'value', field: 'value', headerName: 'Value', aggFunc: 'max' }
658
+ ];
659
+
660
+ service.createApi(aggColumnDefs, aggData);
661
+ const agg = service.calculateColumnAggregations(aggData);
662
+ expect(agg['value']).toBe(300);
663
+ });
664
+
665
+ it('should calculate count aggregation', () => {
666
+ const aggData: any[] = [
667
+ { id: 1, name: 'Item 1' },
668
+ { id: 2, name: 'Item 2' },
669
+ { id: 3, name: 'Item 3' }
670
+ ];
671
+ const aggColumnDefs: ColDef[] = [
672
+ { colId: 'id', field: 'id', headerName: 'ID', aggFunc: 'count' }
673
+ ];
674
+
675
+ service.createApi(aggColumnDefs, aggData);
676
+ const agg = service.calculateColumnAggregations(aggData);
677
+ expect(agg['id']).toBe(3);
678
+ });
679
+
680
+ it('should support custom aggregation function', () => {
681
+ const aggData: any[] = [
682
+ { id: 1, value: 100 },
683
+ { id: 2, value: 200 },
684
+ { id: 3, value: 300 }
685
+ ];
686
+ const customAggFunc = (params: any) => {
687
+ return params.values.reduce((sum: number, v: number) => sum + v, 0) * 2;
688
+ };
689
+ const aggColumnDefs: ColDef[] = [
690
+ { colId: 'value', field: 'value', headerName: 'Value', aggFunc: customAggFunc }
691
+ ];
692
+
693
+ service.createApi(aggColumnDefs, aggData);
694
+ const agg = service.calculateColumnAggregations(aggData);
695
+ expect(agg['value']).toBe(1200); // (100+200+300) * 2
696
+ });
697
+
698
+ // Excel Export Tests
699
+ it('should export data as CSV', () => {
700
+ const exportData: any[] = [
701
+ { id: 1, name: 'John', email: 'john@example.com' },
702
+ { id: 2, name: 'Jane', email: 'jane@example.com' }
703
+ ];
704
+ const exportColumnDefs: ColDef[] = [
705
+ { colId: 'id', field: 'id', headerName: 'ID' },
706
+ { colId: 'name', field: 'name', headerName: 'Name' },
707
+ { colId: 'email', field: 'email', headerName: 'Email' }
708
+ ];
709
+
710
+ const exportApi = service.createApi(exportColumnDefs, exportData);
711
+
712
+ // Mock downloadFile to avoid browser API issues in tests
713
+ (service as any).downloadFile = vi.fn();
714
+ expect(() => exportApi.exportDataAsCsv()).not.toThrow();
715
+ expect((service as any).downloadFile).toHaveBeenCalled();
716
+ });
717
+
718
+ it('should export with custom filename', () => {
719
+ const exportData: any[] = [{ id: 1, name: 'Test' }];
720
+ const exportColumnDefs: ColDef[] = [
721
+ { colId: 'id', field: 'id', headerName: 'ID' },
722
+ { colId: 'name', field: 'name', headerName: 'Name' }
723
+ ];
724
+
725
+ const exportApi = service.createApi(exportColumnDefs, exportData);
726
+ (service as any).downloadFile = vi.fn();
727
+
728
+ exportApi.exportDataAsCsv({ fileName: 'custom-export.csv' });
729
+ expect((service as any).downloadFile).toHaveBeenCalledWith(
730
+ expect.any(String),
731
+ 'custom-export.csv',
732
+ expect.any(String)
733
+ );
734
+ });
735
+
736
+ it('should export only selected columns', () => {
737
+ const exportData: any[] = [
738
+ { id: 1, name: 'John', email: 'john@example.com' },
739
+ { id: 2, name: 'Jane', email: 'jane@example.com' }
740
+ ];
741
+ const exportColumnDefs: ColDef[] = [
742
+ { colId: 'id', field: 'id', headerName: 'ID' },
743
+ { colId: 'name', field: 'name', headerName: 'Name' },
744
+ { colId: 'email', field: 'email', headerName: 'Email' }
745
+ ];
746
+
747
+ const exportApi = service.createApi(exportColumnDefs, exportData);
748
+ (service as any).downloadFile = vi.fn();
749
+
750
+ exportApi.exportDataAsCsv({ columnKeys: ['id', 'name'] });
751
+ const csvContent = (service as any).downloadFile.mock.calls[0][0];
752
+ // Should not contain email column
753
+ expect(csvContent).not.toContain('email');
754
+ });
755
+
756
+ it('should skip headers when specified', () => {
757
+ const exportData: any[] = [{ id: 1, name: 'Test' }];
758
+ const exportColumnDefs: ColDef[] = [
759
+ { colId: 'id', field: 'id', headerName: 'ID' },
760
+ { colId: 'name', field: 'name', headerName: 'Name' }
761
+ ];
762
+
763
+ const exportApi = service.createApi(exportColumnDefs, exportData);
764
+ (service as any).downloadFile = vi.fn();
765
+
766
+ exportApi.exportDataAsCsv({ skipHeader: true });
767
+ const csvContent = (service as any).downloadFile.mock.calls[0][0];
768
+ // First line should be data, not header
769
+ expect(csvContent).not.toMatch(/^ID,/);
770
+ });
771
+
772
+ it('should export data as Excel', async () => {
773
+ const exportData: any[] = [{ id: 1, name: 'Test' }];
774
+ const exportColumnDefs: ColDef[] = [
775
+ { colId: 'id', field: 'id', headerName: 'ID' },
776
+ { colId: 'name', field: 'name', headerName: 'Name' }
777
+ ];
778
+
779
+ const exportApi = service.createApi(exportColumnDefs, exportData);
780
+
781
+ // Mock URL methods
782
+ if (typeof URL.createObjectURL === 'undefined') {
783
+ URL.createObjectURL = vi.fn().mockReturnValue('blob:test');
784
+ }
785
+ if (typeof URL.revokeObjectURL === 'undefined') {
786
+ URL.revokeObjectURL = vi.fn();
787
+ }
788
+
789
+ // We expect it not to throw during the setup phase
790
+ expect(() => exportApi.exportDataAsExcel()).not.toThrow();
791
+ });
792
+
793
+ it('should get displayed row at index', () => {
794
+ const sortedApi = service.createApi(testColumnDefs, [
795
+ { id: 10, name: 'First', age: 20, email: 'first@example.com' },
796
+ { id: 20, name: 'Second', age: 25, email: 'second@example.com' }
797
+ ]);
798
+ const row = sortedApi.getDisplayedRowAtIndex(1);
799
+ expect(row).toBeTruthy();
800
+ expect(row?.data.id).toBe(20);
801
+ });
802
+
803
+ it('should have unique grid id', () => {
804
+ const gridId = api.getGridId();
805
+ expect(gridId).toMatch(/argent-grid-[a-z0-9]{9}/);
806
+ });
807
+
808
+ describe('Corner Cases', () => {
809
+ it('should handle empty row data', () => {
810
+ const emptyApi = service.createApi(testColumnDefs, []);
811
+ expect(emptyApi.getDisplayedRowCount()).toBe(0);
812
+ expect(emptyApi.getRowData()).toEqual([]);
813
+ });
814
+
815
+ it('should handle null/undefined row data', () => {
816
+ const nullApi = service.createApi(testColumnDefs, null);
817
+ expect(nullApi.getDisplayedRowCount()).toBe(0);
818
+ expect(nullApi.getRowData()).toEqual([]);
819
+ });
820
+
821
+ it('should support custom getRowId in gridOptions', () => {
822
+ const data = [
823
+ { customId: 'A', name: 'John' },
824
+ { customId: 'B', name: 'Jane' }
825
+ ];
826
+ const customApi = service.createApi(testColumnDefs, data, {
827
+ getRowId: (params) => params.data.customId
828
+ });
829
+
830
+ expect(customApi.getDisplayedRowCount()).toBe(2);
831
+ expect(customApi.getRowNode('A')).toBeTruthy();
832
+ expect(customApi.getRowNode('B')).toBeTruthy();
833
+ expect(customApi.getRowNode('A')?.data.name).toBe('John');
834
+ });
835
+
836
+ it('should handle rows with missing fields', () => {
837
+ const data = [
838
+ { id: 1, name: 'John' },
839
+ { id: 2, age: 30 }
840
+ ];
841
+ const missingApi = service.createApi(testColumnDefs, data);
842
+ expect(missingApi.getDisplayedRowCount()).toBe(2);
843
+
844
+ // Test sorting on missing field
845
+ missingApi.setSortModel([{ colId: 'age', sort: 'asc' }]);
846
+ // John (undefined age) should be at the end according to compareValues
847
+ expect(missingApi.getDisplayedRowAtIndex(0)?.data.id).toBe(2);
848
+ expect(missingApi.getDisplayedRowAtIndex(1)?.data.id).toBe(1);
849
+ });
850
+
851
+ it('should handle duplicate IDs (last one wins in map)', () => {
852
+ const data = [
853
+ { id: 'dup', name: 'First' },
854
+ { id: 'dup', name: 'Second' }
855
+ ];
856
+ const dupApi = service.createApi(testColumnDefs, data);
857
+
858
+ expect(dupApi.getDisplayedRowCount()).toBe(2);
859
+
860
+ const node = dupApi.getRowNode('dup');
861
+ expect(node?.data.name).toBe('Second');
862
+ });
863
+
864
+ it('should handle update transaction for non-existent row', () => {
865
+ const api = service.createApi(testColumnDefs, [{ id: 1, name: 'John' }]);
866
+ const result = api.applyTransaction({
867
+ update: [{ id: 99, name: 'Missing' }]
868
+ });
869
+
870
+ expect(result?.update.length).toBe(0);
871
+ expect(api.getDisplayedRowCount()).toBe(1);
872
+ });
873
+
874
+ it('should handle sorting on non-existent column', () => {
875
+ const api = service.createApi(testColumnDefs, [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }]);
876
+ // Should not crash
877
+ api.setSortModel([{ colId: 'invalid', sort: 'asc' }]);
878
+ expect(api.getDisplayedRowCount()).toBe(2);
879
+ });
880
+
881
+ it('should preserve selection across transactions', () => {
882
+ const api = service.createApi(testColumnDefs, [
883
+ { id: 1, name: 'John' },
884
+ { id: 2, name: 'Jane' }
885
+ ]);
886
+
887
+ const node1 = api.getRowNode('1')!;
888
+ node1.selected = true;
889
+
890
+ api.applyTransaction({ add: [{ id: 3, name: 'Bob' }] });
891
+
892
+ const sameNode1 = api.getRowNode('1')!;
893
+ expect(sameNode1.selected).toBe(true);
894
+ expect(api.getSelectedNodes().length).toBe(1);
895
+ });
896
+ });
897
+
898
+ describe('Pivoting', () => {
899
+ const pivotColumnDefs: ColDef[] = [
900
+ { field: 'id', headerName: 'ID' },
901
+ { field: 'name', headerName: 'Name' },
902
+ { field: 'dept', headerName: 'Dept', rowGroup: true },
903
+ { field: 'location', headerName: 'Location', pivot: true },
904
+ { field: 'salary', headerName: 'Salary', aggFunc: 'sum' }
905
+ ];
906
+
907
+ const pivotData: any[] = [
908
+ { id: 1, name: 'John', dept: 'Engineering', location: 'NY', salary: 1000 },
909
+ { id: 2, name: 'Jane', dept: 'Engineering', location: 'SF', salary: 2000 },
910
+ { id: 3, name: 'Bob', dept: 'Sales', location: 'NY', salary: 1500 },
911
+ { id: 4, name: 'Alice', dept: 'Sales', location: 'SF', salary: 2500 },
912
+ { id: 5, name: 'Charlie', dept: 'Engineering', location: 'NY', salary: 1200 }
913
+ ];
914
+
915
+ it('should generate pivot columns correctly', () => {
916
+ const api = service.createApi(pivotColumnDefs, pivotData, { pivotMode: true });
917
+ const columns = api.getAllColumns();
918
+ const visibleColumns = columns.filter(c => c.visible);
919
+
920
+ // Auto Group + 2 pivot columns (NY, SF) = 3
921
+ expect(visibleColumns.length).toBe(3);
922
+ expect(visibleColumns.find(c => c.colId.includes('NY'))).toBeTruthy();
923
+ expect(visibleColumns.find(c => c.colId.includes('SF'))).toBeTruthy();
924
+ });
925
+
926
+ it('should calculate pivoted values correctly', () => {
927
+ const api = service.createApi(pivotColumnDefs, pivotData, { pivotMode: true });
928
+
929
+ let engNode = null;
930
+ for (let i = 0; i < api.getDisplayedRowCount(); i++) {
931
+ const node = api.getDisplayedRowAtIndex(i);
932
+ if (node?.group && node.data.dept === 'Engineering') {
933
+ engNode = node;
934
+ break;
935
+ }
936
+ }
937
+
938
+ expect(engNode).toBeTruthy();
939
+ expect((engNode?.data as any).pivotData['NY'].salary).toBe(2200);
940
+ expect((engNode?.data as any).pivotData['SF'].salary).toBe(2000);
941
+ });
942
+
943
+ it('should toggle pivot mode via API', () => {
944
+ const api = service.createApi(pivotColumnDefs, pivotData, { pivotMode: false });
945
+ expect(api.isPivotMode()).toBe(false);
946
+
947
+ api.setPivotMode(true);
948
+ expect(api.isPivotMode()).toBe(true);
949
+ expect(api.getAllColumns().filter(c => c.visible).some(c => c.colId.startsWith('pivot_'))).toBe(true);
950
+
951
+ api.setPivotMode(false);
952
+ expect(api.isPivotMode()).toBe(false);
953
+ });
954
+ });
955
+
956
+ describe('Master/Detail', () => {
957
+ const mdColumnDefs: ColDef[] = [
958
+ { field: 'id', headerName: 'ID' },
959
+ { field: 'name', headerName: 'Name' }
960
+ ];
961
+
962
+ const mdData: any[] = [
963
+ { id: 1, name: 'John' },
964
+ { id: 2, name: 'Jane' }
965
+ ];
966
+
967
+ it('should identify master rows correctly', () => {
968
+ const api = service.createApi(mdColumnDefs, mdData, {
969
+ masterDetail: true,
970
+ isRowMaster: (data) => data.id === 1
971
+ });
972
+
973
+ const node1 = api.getRowNode('1');
974
+ const node2 = api.getRowNode('2');
975
+
976
+ expect(node1?.master).toBe(true);
977
+ expect(node2?.master).toBe(false);
978
+ });
979
+
980
+ it('should insert detail row when master is expanded', () => {
981
+ const api = service.createApi(mdColumnDefs, mdData, {
982
+ masterDetail: true,
983
+ isRowMaster: (data) => data.id === 1
984
+ });
985
+
986
+ expect(api.getDisplayedRowCount()).toBe(2);
987
+
988
+ const node1 = api.getRowNode('1')!;
989
+ api.setRowNodeExpanded(node1, true);
990
+
991
+ // Should now have 3 rows: Master 1, Detail 1, Master 2
992
+ expect(api.getDisplayedRowCount()).toBe(3);
993
+
994
+ const detailNode = api.getDisplayedRowAtIndex(1);
995
+ expect(detailNode?.detail).toBe(true);
996
+ expect(detailNode?.id).toBe('1-detail');
997
+ expect(detailNode?.masterRowNode).toBe(node1);
998
+ });
999
+
1000
+ it('should remove detail row when master is collapsed', () => {
1001
+ const api = service.createApi(mdColumnDefs, mdData, {
1002
+ masterDetail: true,
1003
+ isRowMaster: (data) => data.id === 1
1004
+ });
1005
+
1006
+ const node1 = api.getRowNode('1')!;
1007
+ api.setRowNodeExpanded(node1, true);
1008
+ expect(api.getDisplayedRowCount()).toBe(3);
1009
+
1010
+ api.setRowNodeExpanded(node1, false);
1011
+ expect(api.getDisplayedRowCount()).toBe(2);
1012
+ });
1013
+
1014
+ it('should calculate correct Y positions for variable heights', () => {
1015
+ const api = service.createApi(mdColumnDefs, mdData, {
1016
+ masterDetail: true,
1017
+ isRowMaster: (data) => data.id === 1,
1018
+ rowHeight: 30,
1019
+ detailRowHeight: 100
1020
+ });
1021
+
1022
+ const node1 = api.getRowNode('1')!;
1023
+ api.setRowNodeExpanded(node1, true);
1024
+
1025
+ // Row 0: Master (Y=0, H=30)
1026
+ // Row 1: Detail (Y=30, H=100)
1027
+ // Row 2: Master (Y=130, H=30)
1028
+
1029
+ expect(api.getRowY(0)).toBe(0);
1030
+ expect(api.getRowY(1)).toBe(30);
1031
+ expect(api.getRowY(2)).toBe(130);
1032
+ expect(api.getTotalHeight()).toBe(160);
1033
+
1034
+ expect(api.getRowAtY(15)).toBe(0);
1035
+ expect(api.getRowAtY(50)).toBe(1);
1036
+ expect(api.getRowAtY(140)).toBe(2);
1037
+ });
1038
+ });
1039
+ });