@critical-path/core 0.20.2 → 0.22.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 (85) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +45 -0
  3. package/dist/domain/ddd.test.js +12 -0
  4. package/dist/domain/ddd.test.js.map +1 -1
  5. package/dist/domain/entities.d.ts.map +1 -1
  6. package/dist/domain/entities.js +3 -2
  7. package/dist/domain/entities.js.map +1 -1
  8. package/dist/domain/errors.d.ts +13 -0
  9. package/dist/domain/errors.d.ts.map +1 -0
  10. package/dist/domain/errors.js +19 -0
  11. package/dist/domain/errors.js.map +1 -0
  12. package/dist/domain/events.d.ts +9 -1
  13. package/dist/domain/events.d.ts.map +1 -1
  14. package/dist/domain/events.js.map +1 -1
  15. package/dist/domain/index.d.ts +1 -0
  16. package/dist/domain/index.d.ts.map +1 -1
  17. package/dist/domain/index.js +1 -0
  18. package/dist/domain/index.js.map +1 -1
  19. package/dist/domain/task-duration.test.js +32 -0
  20. package/dist/domain/task-duration.test.js.map +1 -1
  21. package/dist/engine/index.d.ts +11 -0
  22. package/dist/engine/index.d.ts.map +1 -1
  23. package/dist/engine/index.js +112 -19
  24. package/dist/engine/index.js.map +1 -1
  25. package/dist/index.test.js +20 -0
  26. package/dist/index.test.js.map +1 -1
  27. package/dist/status.test.js +97 -1
  28. package/dist/status.test.js.map +1 -1
  29. package/dist/store/sqlite.d.ts.map +1 -1
  30. package/dist/store/sqlite.js +19 -6
  31. package/dist/store/sqlite.js.map +1 -1
  32. package/dist/store/sqlite.test.js +20 -0
  33. package/dist/store/sqlite.test.js.map +1 -1
  34. package/dist/types/index.d.ts +2 -1
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/dist/utils/fractional-index.d.ts +50 -0
  37. package/dist/utils/fractional-index.d.ts.map +1 -0
  38. package/dist/utils/fractional-index.js +226 -0
  39. package/dist/utils/fractional-index.js.map +1 -0
  40. package/dist/utils/fractional-index.test.d.ts +2 -0
  41. package/dist/utils/fractional-index.test.d.ts.map +1 -0
  42. package/dist/utils/fractional-index.test.js +182 -0
  43. package/dist/utils/fractional-index.test.js.map +1 -0
  44. package/dist/utils/index.d.ts +1 -0
  45. package/dist/utils/index.d.ts.map +1 -1
  46. package/dist/utils/index.js +1 -0
  47. package/dist/utils/index.js.map +1 -1
  48. package/dist/utils/key.d.ts +5 -0
  49. package/dist/utils/key.d.ts.map +1 -1
  50. package/dist/utils/key.js +10 -0
  51. package/dist/utils/key.js.map +1 -1
  52. package/dist/utils/key.test.js +18 -1
  53. package/dist/utils/key.test.js.map +1 -1
  54. package/dist/utils/mentions.d.ts +13 -5
  55. package/dist/utils/mentions.d.ts.map +1 -1
  56. package/dist/utils/mentions.js +52 -16
  57. package/dist/utils/mentions.js.map +1 -1
  58. package/dist/utils/mentions.test.js +32 -1
  59. package/dist/utils/mentions.test.js.map +1 -1
  60. package/dist/utils/status.d.ts +80 -1
  61. package/dist/utils/status.d.ts.map +1 -1
  62. package/dist/utils/status.js +194 -0
  63. package/dist/utils/status.js.map +1 -1
  64. package/package.json +1 -5
  65. package/src/domain/ddd.test.ts +22 -0
  66. package/src/domain/entities.ts +3 -2
  67. package/src/domain/errors.ts +19 -0
  68. package/src/domain/events.ts +6 -0
  69. package/src/domain/index.ts +1 -0
  70. package/src/domain/task-duration.test.ts +36 -0
  71. package/src/engine/index.ts +118 -20
  72. package/src/index.test.ts +24 -0
  73. package/src/status.test.ts +130 -2
  74. package/src/store/sqlite.test.ts +22 -0
  75. package/src/store/sqlite.ts +21 -4
  76. package/src/types/index.ts +2 -0
  77. package/src/utils/fractional-index.test.ts +228 -0
  78. package/src/utils/fractional-index.ts +254 -0
  79. package/src/utils/index.ts +2 -0
  80. package/src/utils/key.test.ts +22 -1
  81. package/src/utils/key.ts +11 -0
  82. package/src/utils/mentions.test.ts +37 -1
  83. package/src/utils/mentions.ts +58 -16
  84. package/src/utils/status.ts +247 -0
  85. package/tsconfig.tsbuildinfo +1 -1
@@ -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
  });
@@ -1,4 +1,7 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
2
5
  import { SQLiteStore } from './sqlite.js';
3
6
 
4
7
  describe('SQLiteStore', () => {
@@ -194,3 +197,22 @@ describe('SQLiteStore', () => {
194
197
  expect(remaining).toHaveLength(0);
195
198
  });
196
199
  });
200
+
201
+ describe('SQLiteStore outside the vitest runtime', () => {
202
+ const distEntry = fileURLToPath(new URL('../../dist/index.js', import.meta.url));
203
+
204
+ // vitest injects `require` into ESM modules, which once hid an `eval("require(...)")` failure
205
+ // that broke `new SQLiteStore({ filename })` in plain Node. Run the built package in a real Node process.
206
+ it.skipIf(!existsSync(distEntry))('constructs from a filename in a plain Node ESM process', () => {
207
+ const script = `
208
+ const { SQLiteStore } = await import(${JSON.stringify(pathToFileURL(distEntry).href)});
209
+ const store = new SQLiteStore({ filename: ':memory:' });
210
+ const project = await store.createProject({ key: 'SMOKE', name: 'Smoke' });
211
+ process.stdout.write(project.key);
212
+ `;
213
+ const output = execFileSync(process.execPath, ['--input-type=module', '--no-warnings', '-e', script], {
214
+ encoding: 'utf8'
215
+ });
216
+ expect(output).toBe('SMOKE');
217
+ });
218
+ });
@@ -41,7 +41,13 @@ export class SQLiteStore implements StorageAdapter {
41
41
  throw new Error('SQLiteStore is only available in Node.js environments.');
42
42
  }
43
43
  try {
44
- const nodeSqlite = eval("require('node:sqlite')");
44
+ // `require` is undefined in ESM, and a static import would break browser bundles of core.
45
+ const nodeSqlite = (globalThis as any).process?.getBuiltinModule?.('node:sqlite') as
46
+ | typeof import('node:sqlite')
47
+ | undefined;
48
+ if (!nodeSqlite) {
49
+ throw new Error('process.getBuiltinModule is unavailable (requires Node.js 22.3+)');
50
+ }
45
51
  this.db = new nodeSqlite.DatabaseSync(config.filename || ':memory:');
46
52
  } catch (err: any) {
47
53
  throw new Error(`Failed to load node:sqlite module: ${err.message}`);
@@ -123,6 +129,7 @@ export class SQLiteStore implements StorageAdapter {
123
129
  plannedStartDate TEXT,
124
130
  actualStartDate TEXT,
125
131
  actualEndDate TEXT,
132
+ completedAt TEXT,
126
133
  dueDate TEXT,
127
134
  estimatedHours REAL,
128
135
  loggedHours REAL,
@@ -266,6 +273,12 @@ export class SQLiteStore implements StorageAdapter {
266
273
  } catch {
267
274
  // Column may already exist
268
275
  }
276
+
277
+ try {
278
+ this.db.exec('ALTER TABLE tasks ADD COLUMN completedAt TEXT');
279
+ } catch {
280
+ // Column may already exist
281
+ }
269
282
  }
270
283
 
271
284
  // --- Projects ---
@@ -662,13 +675,13 @@ export class SQLiteStore implements StorageAdapter {
662
675
  const stmt = this.db.prepare(`
663
676
  INSERT INTO tasks (
664
677
  id, projectId, title, description, status, priority, taskType, assigneeId, assignees, reporterId,
665
- reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate,
678
+ reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate, completedAt,
666
679
  dueDate, estimatedHours, loggedHours, actualHours, billableHours,
667
680
  estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes,
668
681
  actualDurationSeconds, inProgressSince, blockedDurationSeconds, blockedSince, progress,
669
682
  isBlocked, blockedReason,
670
683
  tags, customFields, parentId, createdAt, updatedAt
671
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
684
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
672
685
  `);
673
686
  stmt.run(
674
687
  newTask.id,
@@ -689,6 +702,7 @@ export class SQLiteStore implements StorageAdapter {
689
702
  newTask.plannedStartDate || null,
690
703
  newTask.actualStartDate || null,
691
704
  newTask.actualEndDate || null,
705
+ newTask.completedAt || null,
692
706
  newTask.dueDate || null,
693
707
  newTask.estimatedHours ?? null,
694
708
  newTask.loggedHours ?? null,
@@ -727,7 +741,7 @@ export class SQLiteStore implements StorageAdapter {
727
741
  UPDATE tasks SET
728
742
  projectId = ?, title = ?, description = ?, status = ?, priority = ?, taskType = ?,
729
743
  assigneeId = ?, assignees = ?, reporterId = ?, reviewerId = ?, iterationId = ?, teamId = ?, containerId = ?, deliverableId = ?,
730
- plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, dueDate = ?,
744
+ plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, completedAt = ?, dueDate = ?,
731
745
  estimatedHours = ?, loggedHours = ?, actualHours = ?, billableHours = ?,
732
746
  estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?,
733
747
  actualDurationSeconds = ?, inProgressSince = ?, blockedDurationSeconds = ?, blockedSince = ?, progress = ?,
@@ -753,6 +767,7 @@ export class SQLiteStore implements StorageAdapter {
753
767
  updated.plannedStartDate || null,
754
768
  updated.actualStartDate || null,
755
769
  updated.actualEndDate || null,
770
+ updated.completedAt || null,
756
771
  updated.dueDate || null,
757
772
  updated.estimatedHours ?? null,
758
773
  updated.loggedHours ?? null,
@@ -1214,6 +1229,8 @@ export class SQLiteStore implements StorageAdapter {
1214
1229
  inProgressSince: row.inProgressSince || undefined,
1215
1230
  blockedDurationSeconds: row.blockedDurationSeconds ?? undefined,
1216
1231
  blockedSince: row.blockedSince || undefined,
1232
+ actualEndDate: row.actualEndDate || undefined,
1233
+ completedAt: row.completedAt || undefined,
1217
1234
  isBlocked: row.isBlocked !== null && row.isBlocked !== undefined ? Boolean(row.isBlocked) : undefined,
1218
1235
  blockedReason: row.blockedReason || undefined,
1219
1236
  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;
@@ -408,6 +409,7 @@ export interface Webhook {
408
409
  export type WebhookEvent =
409
410
  | 'project.created'
410
411
  | 'project.updated'
412
+ | 'project.deleted'
411
413
  | 'task.created'
412
414
  | 'task.updated'
413
415
  | 'task.deleted'
@@ -0,0 +1,228 @@
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
+ it('handles repeated insertions directly after the same lower bound', () => {
125
+ const lower = 'a0';
126
+ let upper = 'a1';
127
+
128
+ // Repeatedly drop a card just below the first card
129
+ for (let i = 0; i < 50; i++) {
130
+ const next = generateKeyBetween(lower, upper);
131
+ expect(lower < next).toBe(true);
132
+ expect(next < upper).toBe(true);
133
+ upper = next;
134
+ }
135
+ });
136
+
137
+ it('keeps keys strictly ordered under random insertions', () => {
138
+ const keys = ['a0', 'a1'];
139
+ let seed = 42;
140
+ const random = () => {
141
+ seed = (seed * 1103515245 + 12345) % 2147483648;
142
+ return seed / 2147483648;
143
+ };
144
+
145
+ for (let i = 0; i < 500; i++) {
146
+ const idx = Math.floor(random() * (keys.length + 1));
147
+ const next = generateKeyBetween(keys[idx - 1] ?? null, keys[idx] ?? null);
148
+ keys.splice(idx, 0, next);
149
+ }
150
+
151
+ for (let i = 1; i < keys.length; i++) {
152
+ expect(keys[i - 1] < keys[i]).toBe(true);
153
+ }
154
+ });
155
+
156
+ it('throws when the lower bound does not sort before the upper bound', () => {
157
+ expect(() => generateKeyBetween('a1', 'a0')).toThrow(RangeError);
158
+ expect(() => generateKeyBetween('a0', 'a0')).toThrow(RangeError);
159
+ });
160
+ });
161
+
162
+ describe('generateNKeysBetween', () => {
163
+ it('returns empty array for count <= 0', () => {
164
+ expect(generateNKeysBetween('a0', 'a2', 0)).toEqual([]);
165
+ expect(generateNKeysBetween('a0', 'a2', -1)).toEqual([]);
166
+ });
167
+
168
+ it('generates 1 key matching generateKeyBetween', () => {
169
+ const keys = generateNKeysBetween('a0', 'a2', 1);
170
+ expect(keys.length).toBe(1);
171
+ expect(keys[0]).toBe('a1');
172
+ });
173
+
174
+ it('generates N keys in strictly ascending order between a and b', () => {
175
+ const count = 5;
176
+ const a = 'a0';
177
+ const b = 'a1';
178
+ const keys = generateNKeysBetween(a, b, count);
179
+
180
+ expect(keys.length).toBe(count);
181
+
182
+ // Check strictly greater than a
183
+ expect(a < keys[0]).toBe(true);
184
+
185
+ // Check strictly increasing
186
+ for (let i = 0; i < keys.length - 1; i++) {
187
+ expect(keys[i] < keys[i + 1]).toBe(true);
188
+ }
189
+
190
+ // Check strictly less than b
191
+ expect(keys[count - 1] < b).toBe(true);
192
+ });
193
+
194
+ it('generates N keys at swimlane start (a is null)', () => {
195
+ const count = 3;
196
+ const b = 'a0';
197
+ const keys = generateNKeysBetween(null, b, count);
198
+
199
+ expect(keys.length).toBe(count);
200
+ for (let i = 0; i < keys.length - 1; i++) {
201
+ expect(keys[i] < keys[i + 1]).toBe(true);
202
+ }
203
+ expect(keys[count - 1] < b).toBe(true);
204
+ });
205
+
206
+ it('generates N keys at swimlane end (b is null)', () => {
207
+ const count = 4;
208
+ const a = 'a5';
209
+ const keys = generateNKeysBetween(a, null, count);
210
+
211
+ expect(keys.length).toBe(count);
212
+ expect(a < keys[0]).toBe(true);
213
+ for (let i = 0; i < keys.length - 1; i++) {
214
+ expect(keys[i] < keys[i + 1]).toBe(true);
215
+ }
216
+ });
217
+
218
+ it('generates N keys in empty swimlane (both null)', () => {
219
+ const count = 6;
220
+ const keys = generateNKeysBetween(null, null, count);
221
+
222
+ expect(keys.length).toBe(count);
223
+ for (let i = 0; i < keys.length - 1; i++) {
224
+ expect(keys[i] < keys[i + 1]).toBe(true);
225
+ }
226
+ });
227
+ });
228
+ });