202 lines
7.5 KiB
TypeScript
202 lines
7.5 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { MindDB } from '../../src/mind/db.js';
|
|
import {
|
|
ImprovementSignalStore,
|
|
type SignalCategory,
|
|
} from '../../src/mind/improvement-signals.js';
|
|
|
|
describe('ImprovementSignalStore', () => {
|
|
let db: MindDB;
|
|
let store: ImprovementSignalStore;
|
|
|
|
beforeEach(() => {
|
|
db = new MindDB(':memory:');
|
|
store = new ImprovementSignalStore(db);
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
});
|
|
|
|
// ── record ─────────────────────────────────────────────────
|
|
|
|
describe('record', () => {
|
|
it('inserts a new signal on first record', () => {
|
|
const signal = store.record('capability_gap', 'missing:pdf_reader');
|
|
expect(signal.id).toBeDefined();
|
|
expect(signal.category).toBe('capability_gap');
|
|
expect(signal.pattern_key).toBe('missing:pdf_reader');
|
|
expect(signal.count).toBe(1);
|
|
expect(signal.surfaced).toBe(0);
|
|
});
|
|
|
|
it('increments count on duplicate (category, pattern_key)', () => {
|
|
store.record('correction', 'tone:too_formal');
|
|
store.record('correction', 'tone:too_formal');
|
|
const signal = store.record('correction', 'tone:too_formal');
|
|
expect(signal.count).toBe(3);
|
|
});
|
|
|
|
it('updates detail on upsert when new detail is non-empty', () => {
|
|
store.record('correction', 'format:headers', 'Use ## not ###');
|
|
const updated = store.record('correction', 'format:headers', 'Use h2 not h3');
|
|
expect(updated.detail).toBe('Use h2 not h3');
|
|
});
|
|
|
|
it('preserves existing detail when new detail is empty', () => {
|
|
store.record('correction', 'format:headers', 'Use ## not ###');
|
|
const updated = store.record('correction', 'format:headers');
|
|
expect(updated.detail).toBe('Use ## not ###');
|
|
});
|
|
|
|
it('stores metadata as JSON', () => {
|
|
const signal = store.record('workflow_pattern', 'shape:research', undefined, {
|
|
lastTask: 'analyze competitor',
|
|
avgSteps: 4,
|
|
});
|
|
const parsed = JSON.parse(signal.metadata);
|
|
expect(parsed.lastTask).toBe('analyze competitor');
|
|
expect(parsed.avgSteps).toBe(4);
|
|
});
|
|
|
|
it('keeps different pattern_keys separate within same category', () => {
|
|
store.record('capability_gap', 'missing:pdf_reader');
|
|
store.record('capability_gap', 'missing:web_search');
|
|
const gaps = store.getByCategory('capability_gap');
|
|
expect(gaps).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
// ── getByCategory ──────────────────────────────────────────
|
|
|
|
describe('getByCategory', () => {
|
|
it('returns only signals for the requested category', () => {
|
|
store.record('capability_gap', 'missing:pdf');
|
|
store.record('correction', 'tone:casual');
|
|
store.record('capability_gap', 'missing:search');
|
|
|
|
const gaps = store.getByCategory('capability_gap');
|
|
expect(gaps).toHaveLength(2);
|
|
expect(gaps.every(s => s.category === 'capability_gap')).toBe(true);
|
|
});
|
|
|
|
it('returns signals ordered by count descending', () => {
|
|
store.record('correction', 'a');
|
|
store.record('correction', 'b');
|
|
store.record('correction', 'b');
|
|
store.record('correction', 'b');
|
|
store.record('correction', 'a');
|
|
|
|
const corrections = store.getByCategory('correction');
|
|
expect(corrections[0].pattern_key).toBe('b');
|
|
expect(corrections[0].count).toBe(3);
|
|
expect(corrections[1].pattern_key).toBe('a');
|
|
expect(corrections[1].count).toBe(2);
|
|
});
|
|
|
|
it('returns empty array for category with no signals', () => {
|
|
expect(store.getByCategory('workflow_pattern')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── getActionable ──────────────────────────────────────────
|
|
|
|
describe('getActionable', () => {
|
|
it('returns signals above default thresholds', () => {
|
|
// capability_gap threshold = 2
|
|
store.record('capability_gap', 'missing:pdf');
|
|
store.record('capability_gap', 'missing:pdf');
|
|
|
|
const actionable = store.getActionable();
|
|
expect(actionable).toHaveLength(1);
|
|
expect(actionable[0].pattern_key).toBe('missing:pdf');
|
|
expect(actionable[0].parsedMetadata).toBeDefined();
|
|
});
|
|
|
|
it('excludes signals below threshold', () => {
|
|
// correction threshold = 3, only recorded twice
|
|
store.record('correction', 'tone:casual');
|
|
store.record('correction', 'tone:casual');
|
|
|
|
const actionable = store.getActionable();
|
|
expect(actionable).toHaveLength(0);
|
|
});
|
|
|
|
it('excludes already-surfaced signals', () => {
|
|
store.record('capability_gap', 'missing:pdf');
|
|
const signal = store.record('capability_gap', 'missing:pdf');
|
|
store.markSurfaced(signal.id);
|
|
|
|
const actionable = store.getActionable();
|
|
expect(actionable).toHaveLength(0);
|
|
});
|
|
|
|
it('caps at 3 results (MAX_ACTIONABLE)', () => {
|
|
for (let i = 0; i < 5; i++) {
|
|
const key = `gap_${i}`;
|
|
store.record('capability_gap', key);
|
|
store.record('capability_gap', key);
|
|
}
|
|
|
|
const actionable = store.getActionable();
|
|
expect(actionable.length).toBeLessThanOrEqual(3);
|
|
});
|
|
|
|
it('accepts custom thresholds', () => {
|
|
store.record('correction', 'tone:casual'); // count = 1
|
|
|
|
// Lower threshold to 1 — should now be actionable
|
|
const actionable = store.getActionable({ correction: 1 });
|
|
expect(actionable.some(s => s.pattern_key === 'tone:casual')).toBe(true);
|
|
});
|
|
|
|
it('includes parsedMetadata on results', () => {
|
|
store.record('capability_gap', 'missing:pdf', undefined, { tool: 'pdf_reader' });
|
|
store.record('capability_gap', 'missing:pdf');
|
|
|
|
const actionable = store.getActionable();
|
|
expect(actionable[0].parsedMetadata).toEqual({ tool: 'pdf_reader' });
|
|
});
|
|
});
|
|
|
|
// ── markSurfaced ───────────────────────────────────────────
|
|
|
|
describe('markSurfaced', () => {
|
|
it('sets surfaced=1 and surfaced_at', () => {
|
|
const signal = store.record('correction', 'tone:casual');
|
|
store.markSurfaced(signal.id);
|
|
|
|
const updated = store.get(signal.id);
|
|
expect(updated?.surfaced).toBe(1);
|
|
expect(updated?.surfaced_at).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── getByKey ───────────────────────────────────────────────
|
|
|
|
describe('getByKey', () => {
|
|
it('returns signal by category + pattern_key', () => {
|
|
store.record('workflow_pattern', 'shape:research');
|
|
const found = store.getByKey('workflow_pattern', 'shape:research');
|
|
expect(found).toBeDefined();
|
|
expect(found?.pattern_key).toBe('shape:research');
|
|
});
|
|
|
|
it('returns undefined for non-existent key', () => {
|
|
expect(store.getByKey('correction', 'nonexistent')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── ensureTable (backward compat) ──────────────────────────
|
|
|
|
describe('ensureTable', () => {
|
|
it('creates table even on databases without it in schema', () => {
|
|
// The :memory: DB already has the table from schema.ts,
|
|
// but ensureTable should handle it gracefully
|
|
const store2 = new ImprovementSignalStore(db);
|
|
const signal = store2.record('capability_gap', 'test:compat');
|
|
expect(signal.count).toBe(1);
|
|
});
|
|
});
|
|
});
|