This commit is contained in:
209
packages/hive-mind-core/tests/mind/concept-tracker.test.ts
Normal file
209
packages/hive-mind-core/tests/mind/concept-tracker.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { MindDB } from '../../src/mind/db.js';
|
||||
import { ConceptTracker } from '../../src/mind/concept-tracker.js';
|
||||
|
||||
describe('ConceptTracker (F19)', () => {
|
||||
let db: MindDB;
|
||||
let tracker: ConceptTracker;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new MindDB(':memory:');
|
||||
tracker = new ConceptTracker(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
describe('upsertConcept', () => {
|
||||
it('creates a new concept with defaults', () => {
|
||||
const entry = tracker.upsertConcept('TypeScript generics');
|
||||
expect(entry.concept).toBe('TypeScript generics');
|
||||
expect(entry.mastery_level).toBe(1);
|
||||
expect(entry.times_correct).toBe(0);
|
||||
expect(entry.times_wrong).toBe(0);
|
||||
expect(entry.notes).toBe('');
|
||||
expect(entry.created_at).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates a concept with custom mastery level', () => {
|
||||
const entry = tracker.upsertConcept('SQL joins', { mastery_level: 3 });
|
||||
expect(entry.mastery_level).toBe(3);
|
||||
});
|
||||
|
||||
it('creates a concept with notes', () => {
|
||||
const entry = tracker.upsertConcept('React hooks', { notes: 'Focus on useEffect cleanup' });
|
||||
expect(entry.notes).toBe('Focus on useEffect cleanup');
|
||||
});
|
||||
|
||||
it('updates existing concept mastery level', () => {
|
||||
tracker.upsertConcept('Git rebase', { mastery_level: 2 });
|
||||
const updated = tracker.upsertConcept('Git rebase', { mastery_level: 4 });
|
||||
expect(updated.mastery_level).toBe(4);
|
||||
});
|
||||
|
||||
it('updates existing concept notes', () => {
|
||||
tracker.upsertConcept('Docker', { notes: 'basics' });
|
||||
const updated = tracker.upsertConcept('Docker', { notes: 'Dockerfile multi-stage builds' });
|
||||
expect(updated.notes).toBe('Dockerfile multi-stage builds');
|
||||
});
|
||||
|
||||
it('clamps mastery level to 1-5 range', () => {
|
||||
const low = tracker.upsertConcept('test-low', { mastery_level: 0 });
|
||||
expect(low.mastery_level).toBe(1);
|
||||
|
||||
const high = tracker.upsertConcept('test-high', { mastery_level: 10 });
|
||||
expect(high.mastery_level).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConcept', () => {
|
||||
it('returns a concept by name', () => {
|
||||
tracker.upsertConcept('Rust ownership');
|
||||
const found = tracker.getConcept('Rust ownership');
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.concept).toBe('Rust ownership');
|
||||
});
|
||||
|
||||
it('returns undefined for nonexistent concept', () => {
|
||||
expect(tracker.getConcept('nonexistent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listConcepts', () => {
|
||||
it('lists all concepts', () => {
|
||||
tracker.upsertConcept('A', { mastery_level: 1 });
|
||||
tracker.upsertConcept('B', { mastery_level: 3 });
|
||||
tracker.upsertConcept('C', { mastery_level: 5 });
|
||||
expect(tracker.listConcepts()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('filters by minimum mastery', () => {
|
||||
tracker.upsertConcept('Low', { mastery_level: 1 });
|
||||
tracker.upsertConcept('Mid', { mastery_level: 3 });
|
||||
tracker.upsertConcept('High', { mastery_level: 5 });
|
||||
const filtered = tracker.listConcepts(3);
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered.every(c => c.mastery_level >= 3)).toBe(true);
|
||||
});
|
||||
|
||||
it('filters by maximum mastery', () => {
|
||||
tracker.upsertConcept('Low', { mastery_level: 1 });
|
||||
tracker.upsertConcept('Mid', { mastery_level: 3 });
|
||||
tracker.upsertConcept('High', { mastery_level: 5 });
|
||||
const filtered = tracker.listConcepts(undefined, 2);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].concept).toBe('Low');
|
||||
});
|
||||
|
||||
it('filters by mastery range', () => {
|
||||
tracker.upsertConcept('A', { mastery_level: 1 });
|
||||
tracker.upsertConcept('B', { mastery_level: 2 });
|
||||
tracker.upsertConcept('C', { mastery_level: 3 });
|
||||
tracker.upsertConcept('D', { mastery_level: 4 });
|
||||
const filtered = tracker.listConcepts(2, 3);
|
||||
expect(filtered).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordAnswer', () => {
|
||||
it('increases mastery on correct answer', () => {
|
||||
tracker.upsertConcept('Promises', { mastery_level: 2 });
|
||||
const updated = tracker.recordAnswer('Promises', true);
|
||||
expect(updated.mastery_level).toBe(3);
|
||||
expect(updated.times_correct).toBe(1);
|
||||
expect(updated.times_wrong).toBe(0);
|
||||
expect(updated.last_tested).toBeDefined();
|
||||
});
|
||||
|
||||
it('decreases mastery on wrong answer', () => {
|
||||
tracker.upsertConcept('Closures', { mastery_level: 3 });
|
||||
const updated = tracker.recordAnswer('Closures', false);
|
||||
expect(updated.mastery_level).toBe(2);
|
||||
expect(updated.times_wrong).toBe(1);
|
||||
});
|
||||
|
||||
it('caps mastery at 5', () => {
|
||||
tracker.upsertConcept('HTML', { mastery_level: 5 });
|
||||
const updated = tracker.recordAnswer('HTML', true);
|
||||
expect(updated.mastery_level).toBe(5);
|
||||
expect(updated.times_correct).toBe(1);
|
||||
});
|
||||
|
||||
it('floors mastery at 1', () => {
|
||||
tracker.upsertConcept('Assembly', { mastery_level: 1 });
|
||||
const updated = tracker.recordAnswer('Assembly', false);
|
||||
expect(updated.mastery_level).toBe(1);
|
||||
expect(updated.times_wrong).toBe(1);
|
||||
});
|
||||
|
||||
it('auto-creates concept on first answer if not exists', () => {
|
||||
const entry = tracker.recordAnswer('New concept', true);
|
||||
expect(entry.concept).toBe('New concept');
|
||||
expect(entry.mastery_level).toBe(2); // correct = start at 2
|
||||
expect(entry.times_correct).toBe(1);
|
||||
});
|
||||
|
||||
it('auto-creates concept with mastery 1 on wrong answer', () => {
|
||||
const entry = tracker.recordAnswer('Hard concept', false);
|
||||
expect(entry.mastery_level).toBe(1);
|
||||
expect(entry.times_wrong).toBe(1);
|
||||
});
|
||||
|
||||
it('accumulates correct and wrong counts', () => {
|
||||
tracker.upsertConcept('CSS Grid', { mastery_level: 3 });
|
||||
tracker.recordAnswer('CSS Grid', true);
|
||||
tracker.recordAnswer('CSS Grid', true);
|
||||
tracker.recordAnswer('CSS Grid', false);
|
||||
const entry = tracker.getConcept('CSS Grid')!;
|
||||
expect(entry.times_correct).toBe(2);
|
||||
expect(entry.times_wrong).toBe(1);
|
||||
// 3 + 1 + 1 - 1 = 4
|
||||
expect(entry.mastery_level).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDueForReview', () => {
|
||||
it('returns concepts with mastery < 4', () => {
|
||||
tracker.upsertConcept('Easy', { mastery_level: 5 });
|
||||
tracker.upsertConcept('Medium', { mastery_level: 3 });
|
||||
tracker.upsertConcept('Hard', { mastery_level: 1 });
|
||||
|
||||
const due = tracker.getDueForReview();
|
||||
expect(due).toHaveLength(2);
|
||||
// Hard (1) should come before Medium (3)
|
||||
expect(due[0].concept).toBe('Hard');
|
||||
expect(due[1].concept).toBe('Medium');
|
||||
});
|
||||
|
||||
it('excludes mastered concepts (level 4+)', () => {
|
||||
tracker.upsertConcept('Mastered', { mastery_level: 4 });
|
||||
tracker.upsertConcept('NotYet', { mastery_level: 2 });
|
||||
|
||||
const due = tracker.getDueForReview();
|
||||
expect(due).toHaveLength(1);
|
||||
expect(due[0].concept).toBe('NotYet');
|
||||
});
|
||||
|
||||
it('respects limit parameter', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
tracker.upsertConcept(`Concept ${i}`, { mastery_level: 1 });
|
||||
}
|
||||
expect(tracker.getDueForReview(5)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('returns empty array when all concepts are mastered', () => {
|
||||
tracker.upsertConcept('A', { mastery_level: 4 });
|
||||
tracker.upsertConcept('B', { mastery_level: 5 });
|
||||
expect(tracker.getDueForReview()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('table idempotency', () => {
|
||||
it('creating multiple ConceptTracker instances on same DB does not error', () => {
|
||||
const tracker2 = new ConceptTracker(db);
|
||||
tracker.upsertConcept('test');
|
||||
expect(tracker2.getConcept('test')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user