@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.
- package/CHANGELOG.md +44 -0
- package/README.md +45 -0
- package/dist/domain/ddd.test.js +12 -0
- package/dist/domain/ddd.test.js.map +1 -1
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js +3 -2
- package/dist/domain/entities.js.map +1 -1
- package/dist/domain/errors.d.ts +13 -0
- package/dist/domain/errors.d.ts.map +1 -0
- package/dist/domain/errors.js +19 -0
- package/dist/domain/errors.js.map +1 -0
- package/dist/domain/events.d.ts +9 -1
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js.map +1 -1
- package/dist/domain/index.d.ts +1 -0
- package/dist/domain/index.d.ts.map +1 -1
- package/dist/domain/index.js +1 -0
- package/dist/domain/index.js.map +1 -1
- package/dist/domain/task-duration.test.js +32 -0
- package/dist/domain/task-duration.test.js.map +1 -1
- package/dist/engine/index.d.ts +11 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +112 -19
- package/dist/engine/index.js.map +1 -1
- package/dist/index.test.js +20 -0
- package/dist/index.test.js.map +1 -1
- package/dist/status.test.js +97 -1
- package/dist/status.test.js.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +19 -6
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/sqlite.test.js +20 -0
- package/dist/store/sqlite.test.js.map +1 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/fractional-index.d.ts +50 -0
- package/dist/utils/fractional-index.d.ts.map +1 -0
- package/dist/utils/fractional-index.js +226 -0
- package/dist/utils/fractional-index.js.map +1 -0
- package/dist/utils/fractional-index.test.d.ts +2 -0
- package/dist/utils/fractional-index.test.d.ts.map +1 -0
- package/dist/utils/fractional-index.test.js +182 -0
- package/dist/utils/fractional-index.test.js.map +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/key.d.ts +5 -0
- package/dist/utils/key.d.ts.map +1 -1
- package/dist/utils/key.js +10 -0
- package/dist/utils/key.js.map +1 -1
- package/dist/utils/key.test.js +18 -1
- package/dist/utils/key.test.js.map +1 -1
- package/dist/utils/mentions.d.ts +13 -5
- package/dist/utils/mentions.d.ts.map +1 -1
- package/dist/utils/mentions.js +52 -16
- package/dist/utils/mentions.js.map +1 -1
- package/dist/utils/mentions.test.js +32 -1
- package/dist/utils/mentions.test.js.map +1 -1
- package/dist/utils/status.d.ts +80 -1
- package/dist/utils/status.d.ts.map +1 -1
- package/dist/utils/status.js +194 -0
- package/dist/utils/status.js.map +1 -1
- package/package.json +1 -5
- package/src/domain/ddd.test.ts +22 -0
- package/src/domain/entities.ts +3 -2
- package/src/domain/errors.ts +19 -0
- package/src/domain/events.ts +6 -0
- package/src/domain/index.ts +1 -0
- package/src/domain/task-duration.test.ts +36 -0
- package/src/engine/index.ts +118 -20
- package/src/index.test.ts +24 -0
- package/src/status.test.ts +130 -2
- package/src/store/sqlite.test.ts +22 -0
- package/src/store/sqlite.ts +21 -4
- package/src/types/index.ts +2 -0
- package/src/utils/fractional-index.test.ts +228 -0
- package/src/utils/fractional-index.ts +254 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/key.test.ts +22 -1
- package/src/utils/key.ts +11 -0
- package/src/utils/mentions.test.ts +37 -1
- package/src/utils/mentions.ts +58 -16
- package/src/utils/status.ts +247 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/status.test.ts
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { CriticalPathEngine } from './engine/index.js';
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
});
|
package/src/store/sqlite.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/store/sqlite.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
+
});
|