196 lines
6.6 KiB
TypeScript
196 lines
6.6 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { MindDB } from '../../src/mind/db.js';
|
|
import { AwarenessLayer, type AwarenessItem, type AwarenessCategory } from '../../src/mind/awareness.js';
|
|
|
|
describe('Awareness Layer (Layer 1)', () => {
|
|
let db: MindDB;
|
|
let awareness: AwarenessLayer;
|
|
|
|
beforeEach(() => {
|
|
db = new MindDB(':memory:');
|
|
awareness = new AwarenessLayer(db);
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
});
|
|
|
|
describe('CRUD operations', () => {
|
|
it('adds an active task', () => {
|
|
const item = awareness.add('task', 'Review pull request #42', 5);
|
|
expect(item.id).toBeDefined();
|
|
expect(item.category).toBe('task');
|
|
expect(item.content).toBe('Review pull request #42');
|
|
expect(item.priority).toBe(5);
|
|
});
|
|
|
|
it('adds a recent action', () => {
|
|
const item = awareness.add('action', 'Sent email to team');
|
|
expect(item.category).toBe('action');
|
|
expect(item.priority).toBe(0); // default
|
|
});
|
|
|
|
it('adds a pending item', () => {
|
|
const item = awareness.add('pending', 'Waiting for API response');
|
|
expect(item.category).toBe('pending');
|
|
});
|
|
|
|
it('adds a context flag', () => {
|
|
const item = awareness.add('flag', 'user_prefers_dark_mode');
|
|
expect(item.category).toBe('flag');
|
|
});
|
|
|
|
it('removes an item by id', () => {
|
|
const item = awareness.add('task', 'Delete me');
|
|
awareness.remove(item.id);
|
|
const all = awareness.getAll();
|
|
expect(all).toHaveLength(0);
|
|
});
|
|
|
|
it('updates an item', () => {
|
|
const item = awareness.add('task', 'Original', 1);
|
|
const updated = awareness.update(item.id, { content: 'Updated', priority: 10 });
|
|
expect(updated.content).toBe('Updated');
|
|
expect(updated.priority).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe('retrieval', () => {
|
|
it('returns items ordered by priority (highest first)', () => {
|
|
awareness.add('task', 'Low priority', 1);
|
|
awareness.add('task', 'High priority', 10);
|
|
awareness.add('task', 'Medium priority', 5);
|
|
|
|
const items = awareness.getAll();
|
|
expect(items[0].content).toBe('High priority');
|
|
expect(items[1].content).toBe('Medium priority');
|
|
expect(items[2].content).toBe('Low priority');
|
|
});
|
|
|
|
it('filters by category', () => {
|
|
awareness.add('task', 'Task 1');
|
|
awareness.add('action', 'Action 1');
|
|
awareness.add('flag', 'Flag 1');
|
|
|
|
const tasks = awareness.getByCategory('task');
|
|
expect(tasks).toHaveLength(1);
|
|
expect(tasks[0].category).toBe('task');
|
|
});
|
|
|
|
it('limits to 10 items per the spec', () => {
|
|
for (let i = 0; i < 15; i++) {
|
|
awareness.add('task', `Task ${i}`, i);
|
|
}
|
|
const items = awareness.getAll();
|
|
expect(items).toHaveLength(10);
|
|
// Should return the 10 highest priority
|
|
expect(items[0].priority).toBe(14);
|
|
});
|
|
});
|
|
|
|
describe('clear/reset', () => {
|
|
it('clears all awareness items', () => {
|
|
awareness.add('task', 'Task 1');
|
|
awareness.add('action', 'Action 1');
|
|
awareness.clear();
|
|
expect(awareness.getAll()).toHaveLength(0);
|
|
});
|
|
|
|
it('clears by category', () => {
|
|
awareness.add('task', 'Task 1');
|
|
awareness.add('action', 'Action 1');
|
|
awareness.clearCategory('task');
|
|
const all = awareness.getAll();
|
|
expect(all).toHaveLength(1);
|
|
expect(all[0].category).toBe('action');
|
|
});
|
|
});
|
|
|
|
describe('expiration', () => {
|
|
it('can set an expiration time', () => {
|
|
const item = awareness.add('flag', 'Temporary flag', 0, '2020-01-01T00:00:00');
|
|
expect(item.expires_at).toBe('2020-01-01T00:00:00');
|
|
});
|
|
|
|
it('filters out expired items', () => {
|
|
awareness.add('flag', 'Expired', 0, '2020-01-01T00:00:00');
|
|
awareness.add('flag', 'Active', 0);
|
|
const items = awareness.getAll();
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].content).toBe('Active');
|
|
});
|
|
|
|
// Regression: ISO-8601 strings with `T` separator and `Z` suffix
|
|
// (what `new Date(...).toISOString()` returns) used to ASCII-sort
|
|
// greater than SQLite's `datetime('now')` output ("YYYY-MM-DD HH:MM:SS",
|
|
// space separator, no Z), because `T` (0x54) > ` ` (0x20). That meant any
|
|
// ISO-formatted `expires_at` silently never expired, regardless of its
|
|
// actual time value. The fix wraps `expires_at` in SQLite's `datetime()`
|
|
// to normalize both sides of the comparison.
|
|
it('filters out expired items written in ISO-8601 format with Z suffix', () => {
|
|
const oneMinuteAgoIso = new Date(Date.now() - 60_000).toISOString();
|
|
const oneMinuteHenceIso = new Date(Date.now() + 60_000).toISOString();
|
|
|
|
awareness.add('flag', 'ExpiredIso', 0, oneMinuteAgoIso);
|
|
awareness.add('flag', 'AliveIso', 0, oneMinuteHenceIso);
|
|
|
|
const items = awareness.getAll();
|
|
expect(items.map((i) => i.content)).toEqual(['AliveIso']);
|
|
});
|
|
|
|
it('filters by category while respecting ISO-format expiry', () => {
|
|
const oneMinuteAgoIso = new Date(Date.now() - 60_000).toISOString();
|
|
const oneMinuteHenceIso = new Date(Date.now() + 60_000).toISOString();
|
|
|
|
awareness.add('task', 'ExpiredTask', 0, oneMinuteAgoIso);
|
|
awareness.add('task', 'AliveTask', 0, oneMinuteHenceIso);
|
|
|
|
const tasks = awareness.getByCategory('task');
|
|
expect(tasks.map((t) => t.content)).toEqual(['AliveTask']);
|
|
});
|
|
});
|
|
|
|
describe('toContext', () => {
|
|
it('serializes to a context string', () => {
|
|
awareness.add('task', 'Review PR #42', 10);
|
|
awareness.add('action', 'Sent status email', 5);
|
|
awareness.add('flag', 'meeting_in_progress', 1);
|
|
|
|
const ctx = awareness.toContext();
|
|
expect(ctx).toContain('Review PR #42');
|
|
expect(ctx).toContain('Sent status email');
|
|
expect(ctx).toContain('meeting_in_progress');
|
|
});
|
|
|
|
it('context string is under 2000 tokens (estimated)', () => {
|
|
for (let i = 0; i < 10; i++) {
|
|
awareness.add('task', `Task number ${i} with some description text`, i);
|
|
}
|
|
const ctx = awareness.toContext();
|
|
const estimatedTokens = Math.ceil(ctx.length / 4);
|
|
expect(estimatedTokens).toBeLessThan(2000);
|
|
});
|
|
});
|
|
|
|
describe('performance', () => {
|
|
it('full state reconstruction under 50ms (100 iterations)', () => {
|
|
for (let i = 0; i < 10; i++) {
|
|
awareness.add('task', `Task ${i}`, i);
|
|
}
|
|
|
|
// Warm up
|
|
for (let i = 0; i < 5; i++) awareness.getAll();
|
|
|
|
const start = performance.now();
|
|
const iterations = 100;
|
|
for (let i = 0; i < iterations; i++) {
|
|
awareness.getAll();
|
|
}
|
|
const elapsed = performance.now() - start;
|
|
const avgMs = elapsed / iterations;
|
|
|
|
expect(avgMs).toBeLessThan(50);
|
|
});
|
|
});
|
|
});
|