@critical-path/core 0.20.2 → 0.21.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 (57) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +41 -0
  3. package/dist/domain/task-duration.test.js +32 -0
  4. package/dist/domain/task-duration.test.js.map +1 -1
  5. package/dist/engine/index.d.ts.map +1 -1
  6. package/dist/engine/index.js +40 -9
  7. package/dist/engine/index.js.map +1 -1
  8. package/dist/status.test.js +97 -1
  9. package/dist/status.test.js.map +1 -1
  10. package/dist/store/sqlite.d.ts.map +1 -1
  11. package/dist/store/sqlite.js +14 -5
  12. package/dist/store/sqlite.js.map +1 -1
  13. package/dist/types/index.d.ts +1 -0
  14. package/dist/types/index.d.ts.map +1 -1
  15. package/dist/utils/fractional-index.d.ts +50 -0
  16. package/dist/utils/fractional-index.d.ts.map +1 -0
  17. package/dist/utils/fractional-index.js +230 -0
  18. package/dist/utils/fractional-index.js.map +1 -0
  19. package/dist/utils/fractional-index.test.d.ts +2 -0
  20. package/dist/utils/fractional-index.test.d.ts.map +1 -0
  21. package/dist/utils/fractional-index.test.js +151 -0
  22. package/dist/utils/fractional-index.test.js.map +1 -0
  23. package/dist/utils/index.d.ts +1 -0
  24. package/dist/utils/index.d.ts.map +1 -1
  25. package/dist/utils/index.js +1 -0
  26. package/dist/utils/index.js.map +1 -1
  27. package/dist/utils/key.d.ts +5 -0
  28. package/dist/utils/key.d.ts.map +1 -1
  29. package/dist/utils/key.js +10 -0
  30. package/dist/utils/key.js.map +1 -1
  31. package/dist/utils/key.test.js +18 -1
  32. package/dist/utils/key.test.js.map +1 -1
  33. package/dist/utils/mentions.d.ts +13 -5
  34. package/dist/utils/mentions.d.ts.map +1 -1
  35. package/dist/utils/mentions.js +52 -16
  36. package/dist/utils/mentions.js.map +1 -1
  37. package/dist/utils/mentions.test.js +32 -1
  38. package/dist/utils/mentions.test.js.map +1 -1
  39. package/dist/utils/status.d.ts +80 -1
  40. package/dist/utils/status.d.ts.map +1 -1
  41. package/dist/utils/status.js +194 -0
  42. package/dist/utils/status.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/domain/task-duration.test.ts +36 -0
  45. package/src/engine/index.ts +39 -9
  46. package/src/status.test.ts +130 -2
  47. package/src/store/sqlite.ts +14 -3
  48. package/src/types/index.ts +1 -0
  49. package/src/utils/fractional-index.test.ts +191 -0
  50. package/src/utils/fractional-index.ts +260 -0
  51. package/src/utils/index.ts +2 -0
  52. package/src/utils/key.test.ts +22 -1
  53. package/src/utils/key.ts +11 -0
  54. package/src/utils/mentions.test.ts +37 -1
  55. package/src/utils/mentions.ts +58 -16
  56. package/src/utils/status.ts +247 -0
  57. package/tsconfig.tsbuildinfo +1 -1
@@ -373,6 +373,7 @@ export class CriticalPathEngine {
373
373
  const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.category === 'in_progress' ? now : undefined);
374
374
  const inProgressSince = processedInput.inProgressSince ?? taskInput.inProgressSince ?? (statusDef.category === 'in_progress' ? now : undefined);
375
375
  const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? ((statusDef.category === 'completed' || statusDef.category === 'canceled') ? now : undefined);
376
+ const completedAt = processedInput.completedAt ?? taskInput.completedAt ?? (statusDef.category === 'completed' ? now : undefined);
376
377
 
377
378
  const isInitialBlocked = processedInput.isBlocked ?? taskInput.isBlocked ?? false;
378
379
  const blockedSince = processedInput.blockedSince ?? taskInput.blockedSince ?? (isInitialBlocked && statusDef.category === 'in_progress' ? now : undefined);
@@ -397,6 +398,7 @@ export class CriticalPathEngine {
397
398
  plannedStartDate: processedInput.plannedStartDate ?? taskInput.plannedStartDate,
398
399
  actualStartDate,
399
400
  actualEndDate,
401
+ completedAt,
400
402
  dueDate: processedInput.dueDate ?? taskInput.dueDate,
401
403
  estimatedHours: processedInput.estimatedHours ?? taskInput.estimatedHours,
402
404
  loggedHours: processedInput.loggedHours ?? taskInput.loggedHours ?? 0,
@@ -577,26 +579,54 @@ export class CriticalPathEngine {
577
579
  if (isTaskBlocked && !processedUpdates.blockedSince) {
578
580
  processedUpdates.blockedSince = now;
579
581
  }
582
+ // Moving from completed/canceled to in_progress resets completion timestamp and progress
580
583
  if (
581
- (existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
582
- processedUpdates.actualEndDate === undefined
584
+ existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled'
583
585
  ) {
584
- processedUpdates.actualEndDate = undefined;
586
+ if (processedUpdates.actualEndDate === undefined) {
587
+ processedUpdates.actualEndDate = undefined;
588
+ }
589
+ if (processedUpdates.completedAt === undefined) {
590
+ processedUpdates.completedAt = undefined;
591
+ }
592
+ if (existing.progress === 100 && processedUpdates.progress === undefined) {
593
+ processedUpdates.progress = 0;
594
+ }
585
595
  }
586
596
  } else if (
587
597
  (existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
588
- newStatusDef.category === 'not_started' &&
589
- processedUpdates.actualEndDate === undefined
598
+ newStatusDef.category === 'not_started'
590
599
  ) {
591
- processedUpdates.actualEndDate = undefined;
600
+ // Moving from completed/canceled to not_started resets completion timestamp and progress
601
+ if (processedUpdates.actualEndDate === undefined) {
602
+ processedUpdates.actualEndDate = undefined;
603
+ }
604
+ if (processedUpdates.completedAt === undefined) {
605
+ processedUpdates.completedAt = undefined;
606
+ }
607
+ if (existing.progress === 100 && processedUpdates.progress === undefined) {
608
+ processedUpdates.progress = 0;
609
+ }
592
610
  }
593
611
 
594
612
  // 3. Entering completed or canceled
595
- if ((newStatusDef.category === 'completed' || newStatusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
596
- processedUpdates.actualEndDate = now;
597
- if (newStatusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
613
+ if (newStatusDef.category === 'completed') {
614
+ if (!processedUpdates.actualEndDate) {
615
+ processedUpdates.actualEndDate = now;
616
+ }
617
+ if (!processedUpdates.completedAt) {
618
+ processedUpdates.completedAt = now;
619
+ }
620
+ if (processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
598
621
  processedUpdates.progress = 100;
599
622
  }
623
+ } else if (newStatusDef.category === 'canceled') {
624
+ if (!processedUpdates.actualEndDate) {
625
+ processedUpdates.actualEndDate = now;
626
+ }
627
+ if (processedUpdates.completedAt === undefined) {
628
+ processedUpdates.completedAt = undefined;
629
+ }
600
630
  }
601
631
  }
602
632
 
@@ -1,7 +1,22 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { CriticalPathEngine } from './engine/index.js';
3
- import { resolveStatusDefinition, deriveTaskLifecycleState } from './utils/status.js';
4
- import type { Task, StatusDefinition } from './types/index.js';
3
+ import {
4
+ resolveStatusDefinition,
5
+ deriveTaskLifecycleState,
6
+ isDraftTask,
7
+ isArchivedTask,
8
+ isTrashedTask,
9
+ isTrashedOrArchivedTask,
10
+ isWorkflowTask,
11
+ getTaskSemanticStatus,
12
+ isTaskCompleted,
13
+ isTaskInProgress,
14
+ isTaskNotStarted,
15
+ isTaskCanceled,
16
+ isTaskActive,
17
+ isTaskUnassigned
18
+ } from './utils/status.js';
19
+ import type { Task, StatusDefinition, Workflow } from './types/index.js';
5
20
 
6
21
  describe('Universal Semantic Statuses & Implied Statuses', () => {
7
22
  describe('resolveStatusDefinition', () => {
@@ -223,4 +238,117 @@ describe('Universal Semantic Statuses & Implied Statuses', () => {
223
238
  expect(stateB?.blockingTaskIds).toEqual([]);
224
239
  });
225
240
  });
241
+
242
+ describe('Canonical Task Lifecycle & Semantic Status Predicates', () => {
243
+ it('identifies draft, archived, and trashed tasks properly', () => {
244
+ expect(isDraftTask(null)).toBe(false);
245
+ expect(isDraftTask({ isDraft: true })).toBe(true);
246
+ expect(isDraftTask({ isDraft: false })).toBe(false);
247
+
248
+ expect(isArchivedTask(null)).toBe(false);
249
+ expect(isArchivedTask({ archived: true })).toBe(true);
250
+ expect(isArchivedTask({ archivedAt: '2026-09-01T00:00:00Z' })).toBe(true);
251
+ expect(isArchivedTask({ status: 'archived' })).toBe(true);
252
+ expect(isArchivedTask({ customFields: { isArchived: true } })).toBe(true);
253
+ expect(isArchivedTask({ status: 'todo' })).toBe(false);
254
+
255
+ expect(isTrashedTask(null)).toBe(false);
256
+ expect(isTrashedTask({ trashed: true })).toBe(true);
257
+ expect(isTrashedTask({ trashedAt: '2026-09-01T00:00:00Z' })).toBe(true);
258
+ expect(isTrashedTask({ status: 'trashed' })).toBe(true);
259
+ expect(isTrashedTask({ customFields: { isTrashed: true } })).toBe(true);
260
+ expect(isTrashedTask({ status: 'todo' })).toBe(false);
261
+
262
+ expect(isTrashedOrArchivedTask({ trashed: true })).toBe(true);
263
+ expect(isTrashedOrArchivedTask({ archived: true })).toBe(true);
264
+ expect(isTrashedOrArchivedTask({ status: 'todo' })).toBe(false);
265
+ });
266
+
267
+ it('identifies workflow tasks excluding draft, trashed, and archived tasks (UCH-137)', () => {
268
+ expect(isWorkflowTask(null)).toBe(false);
269
+ expect(isWorkflowTask({ status: 'todo' })).toBe(true);
270
+ expect(isWorkflowTask({ status: 'in_progress' })).toBe(true);
271
+ expect(isWorkflowTask({ status: 'done' })).toBe(true);
272
+
273
+ // Draft tasks are excluded
274
+ expect(isWorkflowTask({ status: 'todo', isDraft: true })).toBe(false);
275
+ // Trashed tasks are excluded
276
+ expect(isWorkflowTask({ status: 'todo', trashed: true })).toBe(false);
277
+ // Archived tasks are excluded
278
+ expect(isWorkflowTask({ status: 'done', archived: true })).toBe(false);
279
+ });
280
+
281
+ it('resolves semantic status using workflow, defaults, and custom definitions', () => {
282
+ const mockWorkflow: Workflow = {
283
+ id: 'wf_custom',
284
+ name: 'Custom Flow',
285
+ isDefault: true,
286
+ statuses: [
287
+ { key: 'discovery', label: 'Discovery', category: 'not_started' },
288
+ { key: 'developing', label: 'Developing', category: 'in_progress' },
289
+ { key: 'delivered', label: 'Delivered', category: 'completed' },
290
+ { key: 'dropped', label: 'Dropped', category: 'canceled' }
291
+ ],
292
+ transitions: [],
293
+ createdAt: '2026-09-01T00:00:00Z',
294
+ updatedAt: '2026-09-01T00:00:00Z'
295
+ };
296
+
297
+ expect(getTaskSemanticStatus({ status: 'developing' }, mockWorkflow)).toBe('in_progress');
298
+ expect(getTaskSemanticStatus({ status: 'delivered' }, mockWorkflow)).toBe('completed');
299
+ expect(getTaskSemanticStatus({ status: 'dropped' }, mockWorkflow)).toBe('canceled');
300
+ expect(getTaskSemanticStatus({ status: 'discovery' }, mockWorkflow)).toBe('not_started');
301
+
302
+ // Falls back to standard defaults when not in custom workflow
303
+ expect(getTaskSemanticStatus({ status: 'in_progress' }, mockWorkflow)).toBe('in_progress');
304
+ expect(getTaskSemanticStatus({ status: 'done' })).toBe('completed');
305
+ expect(getTaskSemanticStatus({ status: 'todo' })).toBe('not_started');
306
+ expect(getTaskSemanticStatus({ status: 'canceled' })).toBe('canceled');
307
+ });
308
+
309
+ it('verifies lifecycle predicates (completed, in_progress, not_started, canceled, active)', () => {
310
+ const taskDone = { status: 'done' };
311
+ const taskActive = { status: 'in_progress' };
312
+ const taskTodo = { status: 'todo' };
313
+ const taskCanceled = { status: 'canceled' };
314
+
315
+ expect(isTaskCompleted(taskDone)).toBe(true);
316
+ expect(isTaskCompleted(taskActive)).toBe(false);
317
+
318
+ expect(isTaskInProgress(taskActive)).toBe(true);
319
+ expect(isTaskInProgress(taskDone)).toBe(false);
320
+
321
+ expect(isTaskNotStarted(taskTodo)).toBe(true);
322
+ expect(isTaskNotStarted(taskActive)).toBe(false);
323
+
324
+ expect(isTaskCanceled(taskCanceled)).toBe(true);
325
+ expect(isTaskCanceled(taskDone)).toBe(false);
326
+
327
+ // Active tasks
328
+ expect(isTaskActive(taskTodo)).toBe(true);
329
+ expect(isTaskActive(taskActive)).toBe(true);
330
+ expect(isTaskActive(taskDone)).toBe(false);
331
+ expect(isTaskActive(taskCanceled)).toBe(false);
332
+ expect(isTaskActive({ ...taskActive, isDraft: true })).toBe(false);
333
+ expect(isTaskActive({ ...taskActive, archived: true })).toBe(false);
334
+ expect(isTaskActive({ ...taskActive, trashed: true })).toBe(false);
335
+ });
336
+
337
+ it('identifies unassigned tasks properly', () => {
338
+ expect(isTaskUnassigned(null)).toBe(true);
339
+ expect(isTaskUnassigned({})).toBe(true);
340
+ expect(isTaskUnassigned({ assigneeId: undefined })).toBe(true);
341
+ expect(isTaskUnassigned({ assigneeId: '' })).toBe(true);
342
+ expect(isTaskUnassigned({ assigneeId: 'unassigned' })).toBe(true);
343
+ expect(isTaskUnassigned({ assigneeId: ' UNASSIGNED ' })).toBe(true);
344
+ expect(isTaskUnassigned({ assigneeId: 'user_123' })).toBe(false);
345
+
346
+ // Assignees array checks
347
+ expect(isTaskUnassigned({ assignees: [] })).toBe(true);
348
+ expect(isTaskUnassigned({ assignees: ['unassigned'] })).toBe(true);
349
+ expect(isTaskUnassigned({ assignees: [{ id: 'unassigned', name: 'unassigned' }] })).toBe(true);
350
+ expect(isTaskUnassigned({ assignees: ['user_456'] })).toBe(false);
351
+ expect(isTaskUnassigned({ assignees: [{ id: 'user_456', name: 'Alice' }] })).toBe(false);
352
+ });
353
+ });
226
354
  });
@@ -123,6 +123,7 @@ export class SQLiteStore implements StorageAdapter {
123
123
  plannedStartDate TEXT,
124
124
  actualStartDate TEXT,
125
125
  actualEndDate TEXT,
126
+ completedAt TEXT,
126
127
  dueDate TEXT,
127
128
  estimatedHours REAL,
128
129
  loggedHours REAL,
@@ -266,6 +267,12 @@ export class SQLiteStore implements StorageAdapter {
266
267
  } catch {
267
268
  // Column may already exist
268
269
  }
270
+
271
+ try {
272
+ this.db.exec('ALTER TABLE tasks ADD COLUMN completedAt TEXT');
273
+ } catch {
274
+ // Column may already exist
275
+ }
269
276
  }
270
277
 
271
278
  // --- Projects ---
@@ -662,13 +669,13 @@ export class SQLiteStore implements StorageAdapter {
662
669
  const stmt = this.db.prepare(`
663
670
  INSERT INTO tasks (
664
671
  id, projectId, title, description, status, priority, taskType, assigneeId, assignees, reporterId,
665
- reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate,
672
+ reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate, completedAt,
666
673
  dueDate, estimatedHours, loggedHours, actualHours, billableHours,
667
674
  estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes,
668
675
  actualDurationSeconds, inProgressSince, blockedDurationSeconds, blockedSince, progress,
669
676
  isBlocked, blockedReason,
670
677
  tags, customFields, parentId, createdAt, updatedAt
671
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
678
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
672
679
  `);
673
680
  stmt.run(
674
681
  newTask.id,
@@ -689,6 +696,7 @@ export class SQLiteStore implements StorageAdapter {
689
696
  newTask.plannedStartDate || null,
690
697
  newTask.actualStartDate || null,
691
698
  newTask.actualEndDate || null,
699
+ newTask.completedAt || null,
692
700
  newTask.dueDate || null,
693
701
  newTask.estimatedHours ?? null,
694
702
  newTask.loggedHours ?? null,
@@ -727,7 +735,7 @@ export class SQLiteStore implements StorageAdapter {
727
735
  UPDATE tasks SET
728
736
  projectId = ?, title = ?, description = ?, status = ?, priority = ?, taskType = ?,
729
737
  assigneeId = ?, assignees = ?, reporterId = ?, reviewerId = ?, iterationId = ?, teamId = ?, containerId = ?, deliverableId = ?,
730
- plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, dueDate = ?,
738
+ plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, completedAt = ?, dueDate = ?,
731
739
  estimatedHours = ?, loggedHours = ?, actualHours = ?, billableHours = ?,
732
740
  estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?,
733
741
  actualDurationSeconds = ?, inProgressSince = ?, blockedDurationSeconds = ?, blockedSince = ?, progress = ?,
@@ -753,6 +761,7 @@ export class SQLiteStore implements StorageAdapter {
753
761
  updated.plannedStartDate || null,
754
762
  updated.actualStartDate || null,
755
763
  updated.actualEndDate || null,
764
+ updated.completedAt || null,
756
765
  updated.dueDate || null,
757
766
  updated.estimatedHours ?? null,
758
767
  updated.loggedHours ?? null,
@@ -1214,6 +1223,8 @@ export class SQLiteStore implements StorageAdapter {
1214
1223
  inProgressSince: row.inProgressSince || undefined,
1215
1224
  blockedDurationSeconds: row.blockedDurationSeconds ?? undefined,
1216
1225
  blockedSince: row.blockedSince || undefined,
1226
+ actualEndDate: row.actualEndDate || undefined,
1227
+ completedAt: row.completedAt || undefined,
1217
1228
  isBlocked: row.isBlocked !== null && row.isBlocked !== undefined ? Boolean(row.isBlocked) : undefined,
1218
1229
  blockedReason: row.blockedReason || undefined,
1219
1230
  taskType: row.taskType || undefined,
@@ -205,6 +205,7 @@ export interface Task {
205
205
  plannedStartDate?: string;
206
206
  actualStartDate?: string;
207
207
  actualEndDate?: string;
208
+ completedAt?: string;
208
209
  dueDate?: string;
209
210
  // Duration & Effort (in hours and/or minutes)
210
211
  estimatedHours?: number;
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ encodeInteger,
4
+ decodeInteger,
5
+ getIntegerPart,
6
+ getInitialKey,
7
+ generateKeyBetween,
8
+ generateNKeysBetween,
9
+ compareOrderIndices
10
+ } from './fractional-index.js';
11
+
12
+ describe('fractional-index', () => {
13
+ describe('encodeInteger and decodeInteger', () => {
14
+ it('encodes and decodes non-negative integers', () => {
15
+ const cases = [0, 1, 2, 9, 10, 61, 62, 100, 1000, 3843, 3844];
16
+ for (const n of cases) {
17
+ const encoded = encodeInteger(n);
18
+ expect(decodeInteger(encoded)).toBe(n);
19
+ }
20
+ });
21
+
22
+ it('encodes and decodes negative integers', () => {
23
+ const cases = [-1, -2, -10, -62, -100, -1000];
24
+ for (const n of cases) {
25
+ const encoded = encodeInteger(n);
26
+ expect(decodeInteger(encoded)).toBe(n);
27
+ }
28
+ });
29
+
30
+ it('strictly maintains lexicographical sorting across integers', () => {
31
+ const sequence = [-100, -62, -10, -2, -1, 0, 1, 2, 9, 10, 61, 62, 100, 3843, 3844];
32
+ const encoded = sequence.map((n) => encodeInteger(n));
33
+
34
+ for (let i = 0; i < encoded.length - 1; i++) {
35
+ expect(encoded[i] < encoded[i + 1]).toBe(true);
36
+ expect(compareOrderIndices(encoded[i], encoded[i + 1])).toBeLessThan(0);
37
+ }
38
+
39
+ // Reversing and sorting with compareOrderIndices or standard sort() restores exact order
40
+ const reversed = [...encoded].reverse();
41
+ expect(reversed.sort(compareOrderIndices)).toEqual(encoded);
42
+ expect([...encoded].reverse().sort()).toEqual(encoded);
43
+ });
44
+ });
45
+
46
+ describe('getInitialKey', () => {
47
+ it('generates spaced initial keys', () => {
48
+ const k0 = getInitialKey(0);
49
+ const k1 = getInitialKey(1);
50
+ const k2 = getInitialKey(2);
51
+
52
+ expect(k0).toBe('a0');
53
+ expect(k1).toBe('a2');
54
+ expect(k2).toBe('a4');
55
+
56
+ expect(k0 < k1).toBe(true);
57
+ expect(k1 < k2).toBe(true);
58
+ });
59
+ });
60
+
61
+ describe('generateKeyBetween', () => {
62
+ it('returns default initial key when both bounds are null/undefined', () => {
63
+ expect(generateKeyBetween(null, null)).toBe('a0');
64
+ expect(generateKeyBetween(undefined, undefined)).toBe('a0');
65
+ expect(generateKeyBetween('', '')).toBe('a0');
66
+ });
67
+
68
+ it('generates a key before an upper bound', () => {
69
+ const b0 = 'a0';
70
+ const beforeB0 = generateKeyBetween(null, b0);
71
+ expect(beforeB0 < b0).toBe(true);
72
+
73
+ const b1 = 'a1';
74
+ const beforeB1 = generateKeyBetween(null, b1);
75
+ expect(beforeB1 < b1).toBe(true);
76
+
77
+ const bFractional = 'a0V';
78
+ const beforeFractional = generateKeyBetween(null, bFractional);
79
+ expect(beforeFractional < bFractional).toBe(true);
80
+ });
81
+
82
+ it('generates a key after a lower bound', () => {
83
+ const a0 = 'a0';
84
+ const afterA0 = generateKeyBetween(a0, null);
85
+ expect(a0 < afterA0).toBe(true);
86
+
87
+ const aFractional = 'a0V';
88
+ const afterFractional = generateKeyBetween(aFractional, null);
89
+ expect(aFractional < afterFractional).toBe(true);
90
+ });
91
+
92
+ it('generates key between spaced integers', () => {
93
+ const a = 'a0';
94
+ const b = 'a2';
95
+ const mid = generateKeyBetween(a, b);
96
+
97
+ expect(mid).toBe('a1');
98
+ expect(a < mid).toBe(true);
99
+ expect(mid < b).toBe(true);
100
+ });
101
+
102
+ it('generates fractional key between adjacent integers', () => {
103
+ const a = 'a0';
104
+ const b = 'a1';
105
+ const mid = generateKeyBetween(a, b);
106
+
107
+ expect(a < mid).toBe(true);
108
+ expect(mid < b).toBe(true);
109
+ });
110
+
111
+ it('handles nested fractional insertions without degradation', () => {
112
+ let current = 'a0';
113
+ const target = 'a1';
114
+
115
+ // Perform 50 sequential midpoint insertions
116
+ for (let i = 0; i < 50; i++) {
117
+ const next = generateKeyBetween(current, target);
118
+ expect(current < next).toBe(true);
119
+ expect(next < target).toBe(true);
120
+ current = next;
121
+ }
122
+ });
123
+ });
124
+
125
+ describe('generateNKeysBetween', () => {
126
+ it('returns empty array for count <= 0', () => {
127
+ expect(generateNKeysBetween('a0', 'a2', 0)).toEqual([]);
128
+ expect(generateNKeysBetween('a0', 'a2', -1)).toEqual([]);
129
+ });
130
+
131
+ it('generates 1 key matching generateKeyBetween', () => {
132
+ const keys = generateNKeysBetween('a0', 'a2', 1);
133
+ expect(keys.length).toBe(1);
134
+ expect(keys[0]).toBe('a1');
135
+ });
136
+
137
+ it('generates N keys in strictly ascending order between a and b', () => {
138
+ const count = 5;
139
+ const a = 'a0';
140
+ const b = 'a1';
141
+ const keys = generateNKeysBetween(a, b, count);
142
+
143
+ expect(keys.length).toBe(count);
144
+
145
+ // Check strictly greater than a
146
+ expect(a < keys[0]).toBe(true);
147
+
148
+ // Check strictly increasing
149
+ for (let i = 0; i < keys.length - 1; i++) {
150
+ expect(keys[i] < keys[i + 1]).toBe(true);
151
+ }
152
+
153
+ // Check strictly less than b
154
+ expect(keys[count - 1] < b).toBe(true);
155
+ });
156
+
157
+ it('generates N keys at swimlane start (a is null)', () => {
158
+ const count = 3;
159
+ const b = 'a0';
160
+ const keys = generateNKeysBetween(null, b, count);
161
+
162
+ expect(keys.length).toBe(count);
163
+ for (let i = 0; i < keys.length - 1; i++) {
164
+ expect(keys[i] < keys[i + 1]).toBe(true);
165
+ }
166
+ expect(keys[count - 1] < b).toBe(true);
167
+ });
168
+
169
+ it('generates N keys at swimlane end (b is null)', () => {
170
+ const count = 4;
171
+ const a = 'a5';
172
+ const keys = generateNKeysBetween(a, null, count);
173
+
174
+ expect(keys.length).toBe(count);
175
+ expect(a < keys[0]).toBe(true);
176
+ for (let i = 0; i < keys.length - 1; i++) {
177
+ expect(keys[i] < keys[i + 1]).toBe(true);
178
+ }
179
+ });
180
+
181
+ it('generates N keys in empty swimlane (both null)', () => {
182
+ const count = 6;
183
+ const keys = generateNKeysBetween(null, null, count);
184
+
185
+ expect(keys.length).toBe(count);
186
+ for (let i = 0; i < keys.length - 1; i++) {
187
+ expect(keys[i] < keys[i + 1]).toBe(true);
188
+ }
189
+ });
190
+ });
191
+ });