@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.
- package/CHANGELOG.md +10 -0
- package/README.md +41 -0
- 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.map +1 -1
- package/dist/engine/index.js +40 -9
- package/dist/engine/index.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 +14 -5
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- 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 +230 -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 +151 -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 -1
- package/src/domain/task-duration.test.ts +36 -0
- package/src/engine/index.ts +39 -9
- package/src/status.test.ts +130 -2
- package/src/store/sqlite.ts +14 -3
- package/src/types/index.ts +1 -0
- package/src/utils/fractional-index.test.ts +191 -0
- package/src/utils/fractional-index.ts +260 -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/engine/index.ts
CHANGED
|
@@ -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
|
-
|
|
582
|
-
processedUpdates.actualEndDate === undefined
|
|
584
|
+
existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled'
|
|
583
585
|
) {
|
|
584
|
-
processedUpdates.actualEndDate
|
|
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
|
-
|
|
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 (
|
|
596
|
-
processedUpdates.actualEndDate
|
|
597
|
-
|
|
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
|
|
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.ts
CHANGED
|
@@ -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,
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
+
});
|