210 lines
7.6 KiB
TypeScript
210 lines
7.6 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|