Files
stack/packages/memory/src/adapters/keyword.test.ts
Jarvis 774b76447d
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
fix: rename all packages from @mosaic/* to @mosaicstack/*
- Updated all package.json name fields and dependency references
- Updated all TypeScript/JavaScript imports
- Updated .woodpecker/publish.yml filters and registry paths
- Updated tools/install.sh scope default
- Updated .npmrc registry paths (worktree + host)
- Enhanced update-checker.ts with checkForAllUpdates() multi-package support
- Updated CLI update command to show table of all packages
- Added KNOWN_PACKAGES, formatAllPackagesTable, getInstallAllCommand
- Marked checkForUpdate() with @deprecated JSDoc

Closes #391
2026-04-04 21:43:23 -05:00

299 lines
9.6 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import type { StorageAdapter } from '@mosaicstack/storage';
import { KeywordAdapter } from './keyword.js';
/* ------------------------------------------------------------------ */
/* In-memory mock StorageAdapter */
/* ------------------------------------------------------------------ */
function createMockStorage(): StorageAdapter {
const collections = new Map<string, Map<string, Record<string, unknown>>>();
let idCounter = 0;
function getCollection(name: string): Map<string, Record<string, unknown>> {
if (!collections.has(name)) collections.set(name, new Map());
return collections.get(name)!;
}
const adapter: StorageAdapter = {
name: 'mock',
async create<T extends Record<string, unknown>>(
collection: string,
data: T,
): Promise<T & { id: string }> {
const id = String(++idCounter);
const record = { ...data, id };
getCollection(collection).set(id, record);
return record as T & { id: string };
},
async read<T extends Record<string, unknown>>(
collection: string,
id: string,
): Promise<T | null> {
const record = getCollection(collection).get(id);
return (record as T) ?? null;
},
async update(collection: string, id: string, data: Record<string, unknown>): Promise<boolean> {
const col = getCollection(collection);
const existing = col.get(id);
if (!existing) return false;
col.set(id, { ...existing, ...data });
return true;
},
async delete(collection: string, id: string): Promise<boolean> {
return getCollection(collection).delete(id);
},
async find<T extends Record<string, unknown>>(
collection: string,
filter?: Record<string, unknown>,
): Promise<T[]> {
const col = getCollection(collection);
const results: T[] = [];
for (const record of col.values()) {
if (filter && !matchesFilter(record, filter)) continue;
results.push(record as T);
}
return results;
},
async findOne<T extends Record<string, unknown>>(
collection: string,
filter: Record<string, unknown>,
): Promise<T | null> {
const col = getCollection(collection);
for (const record of col.values()) {
if (matchesFilter(record, filter)) return record as T;
}
return null;
},
async count(collection: string, filter?: Record<string, unknown>): Promise<number> {
const rows = await adapter.find(collection, filter);
return rows.length;
},
async transaction<T>(fn: (tx: StorageAdapter) => Promise<T>): Promise<T> {
return fn(adapter);
},
async migrate(): Promise<void> {},
async close(): Promise<void> {},
};
return adapter;
}
function matchesFilter(record: Record<string, unknown>, filter: Record<string, unknown>): boolean {
for (const [key, value] of Object.entries(filter)) {
if (record[key] !== value) return false;
}
return true;
}
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
describe('KeywordAdapter', () => {
let adapter: KeywordAdapter;
beforeEach(() => {
adapter = new KeywordAdapter({ type: 'keyword', storage: createMockStorage() });
});
/* ---- Preferences ---- */
describe('preferences', () => {
it('should set and get a preference', async () => {
await adapter.setPreference('u1', 'theme', 'dark');
const value = await adapter.getPreference('u1', 'theme');
expect(value).toBe('dark');
});
it('should return null for missing preference', async () => {
const value = await adapter.getPreference('u1', 'nonexistent');
expect(value).toBeNull();
});
it('should upsert an existing preference', async () => {
await adapter.setPreference('u1', 'theme', 'dark');
await adapter.setPreference('u1', 'theme', 'light');
const value = await adapter.getPreference('u1', 'theme');
expect(value).toBe('light');
});
it('should delete a preference', async () => {
await adapter.setPreference('u1', 'theme', 'dark');
const deleted = await adapter.deletePreference('u1', 'theme');
expect(deleted).toBe(true);
const value = await adapter.getPreference('u1', 'theme');
expect(value).toBeNull();
});
it('should return false when deleting nonexistent preference', async () => {
const deleted = await adapter.deletePreference('u1', 'nope');
expect(deleted).toBe(false);
});
it('should list preferences by userId', async () => {
await adapter.setPreference('u1', 'theme', 'dark', 'appearance');
await adapter.setPreference('u1', 'lang', 'en', 'locale');
await adapter.setPreference('u2', 'theme', 'light', 'appearance');
const prefs = await adapter.listPreferences('u1');
expect(prefs).toHaveLength(2);
expect(prefs.map((p) => p.key).sort()).toEqual(['lang', 'theme']);
});
it('should filter preferences by category', async () => {
await adapter.setPreference('u1', 'theme', 'dark', 'appearance');
await adapter.setPreference('u1', 'lang', 'en', 'locale');
const prefs = await adapter.listPreferences('u1', 'appearance');
expect(prefs).toHaveLength(1);
expect(prefs[0]!.key).toBe('theme');
});
});
/* ---- Insights ---- */
describe('insights', () => {
it('should store and retrieve an insight', async () => {
const insight = await adapter.storeInsight({
userId: 'u1',
content: 'TypeScript is great for type safety',
source: 'chat',
category: 'technical',
relevanceScore: 0.9,
});
expect(insight.id).toBeDefined();
expect(insight.content).toBe('TypeScript is great for type safety');
const fetched = await adapter.getInsight(insight.id);
expect(fetched).not.toBeNull();
expect(fetched!.content).toBe('TypeScript is great for type safety');
});
it('should return null for missing insight', async () => {
const result = await adapter.getInsight('nonexistent');
expect(result).toBeNull();
});
it('should delete an insight', async () => {
const insight = await adapter.storeInsight({
userId: 'u1',
content: 'test',
source: 'chat',
category: 'general',
relevanceScore: 0.5,
});
const deleted = await adapter.deleteInsight(insight.id);
expect(deleted).toBe(true);
const fetched = await adapter.getInsight(insight.id);
expect(fetched).toBeNull();
});
});
/* ---- Keyword Search ---- */
describe('searchInsights', () => {
beforeEach(async () => {
await adapter.storeInsight({
userId: 'u1',
content: 'TypeScript provides excellent type safety for JavaScript projects',
source: 'chat',
category: 'technical',
relevanceScore: 0.9,
});
await adapter.storeInsight({
userId: 'u1',
content: 'React hooks simplify state management in components',
source: 'chat',
category: 'technical',
relevanceScore: 0.8,
});
await adapter.storeInsight({
userId: 'u1',
content: 'TypeScript and React work great together for type safe components',
source: 'chat',
category: 'technical',
relevanceScore: 0.85,
});
await adapter.storeInsight({
userId: 'u2',
content: 'TypeScript is popular',
source: 'chat',
category: 'general',
relevanceScore: 0.5,
});
});
it('should find insights by exact keyword', async () => {
const results = await adapter.searchInsights('u1', 'hooks');
expect(results).toHaveLength(1);
expect(results[0]!.content).toContain('hooks');
});
it('should be case-insensitive', async () => {
const results = await adapter.searchInsights('u1', 'TYPESCRIPT');
expect(results.length).toBeGreaterThanOrEqual(1);
for (const r of results) {
expect(r.content.toLowerCase()).toContain('typescript');
}
});
it('should rank multi-word matches higher', async () => {
const results = await adapter.searchInsights('u1', 'TypeScript React');
// The insight mentioning both "TypeScript" and "React" should rank first (score=2)
expect(results[0]!.score).toBe(2);
expect(results[0]!.content).toContain('TypeScript');
expect(results[0]!.content).toContain('React');
});
it('should return empty for no matches', async () => {
const results = await adapter.searchInsights('u1', 'python django');
expect(results).toHaveLength(0);
});
it('should filter by userId', async () => {
const results = await adapter.searchInsights('u2', 'TypeScript');
expect(results).toHaveLength(1);
expect(results[0]!.content).toBe('TypeScript is popular');
});
it('should respect limit option', async () => {
const results = await adapter.searchInsights('u1', 'TypeScript', { limit: 1 });
expect(results).toHaveLength(1);
});
it('should return empty for empty query', async () => {
const results = await adapter.searchInsights('u1', ' ');
expect(results).toHaveLength(0);
});
});
/* ---- Lifecycle ---- */
describe('lifecycle', () => {
it('should have name "keyword"', () => {
expect(adapter.name).toBe('keyword');
});
it('should have null embedder', () => {
expect(adapter.embedder).toBeNull();
});
it('should close without error', async () => {
await expect(adapter.close()).resolves.toBeUndefined();
});
});
});