Files
zalbot/tests/unit/graphmcpClient.test.ts
Kaysser Kayyali 10e0f22598
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 30s
feat: integration testing
2026-06-20 00:32:18 +00:00

232 lines
8.7 KiB
TypeScript

import { vi, describe, it, expect, afterEach } from 'vitest';
vi.mock('../../src/config.js', () => ({
config: {
GRAPHMCP_URL: 'http://localhost:9000',
GRAPHMCP_SCORE_THRESHOLD: 0.68,
},
}));
import { formatNPCMemory, semanticSearch, listEncounters, queryAsNPC } from '../../src/graphmcp/client.js';
import type { NPCQueryResult } from '../../src/graphmcp/client.js';
const emptyResult: NPCQueryResult = {
npc: 'Miriam',
tier: 'local',
horizon_count: 0,
chunks: [],
graph_context: [],
};
describe('formatNPCMemory', () => {
it('returns first-meeting text for null input', () => {
expect(formatNPCMemory(null)).toBe('No prior encounters on record — first meeting.');
});
it('returns first-meeting text for empty result', () => {
expect(formatNPCMemory(emptyResult)).toBe('No prior encounters on record — first meeting.');
});
it('formats graph_context encounters with date and title', () => {
const result: NPCQueryResult = {
...emptyResult,
horizon_count: 1,
graph_context: [{
enc_id: 'e1',
enc_title: 'Market Thief',
enc_type: 'encounter',
enc_timestamp: '2026-05-24T00:00:00Z',
enc_summary: 'A thief stole an apple.',
featured_entities: [],
locations: [],
}],
};
const output = formatNPCMemory(result);
expect(output).toContain('Market Thief');
expect(output).toContain('A thief stole an apple.');
expect(output).toContain('2026-05-24');
});
it('includes chunks above the score threshold', () => {
const result: NPCQueryResult = {
...emptyResult,
horizon_count: 1,
chunks: [{ text: 'Relevant lore about Miriam.', score: 0.85, source: 'lore', author: '', timestamp: '' }],
};
const output = formatNPCMemory(result);
expect(output).toContain('Relevant lore about Miriam.');
});
it('excludes chunks below the score threshold', () => {
const result: NPCQueryResult = {
...emptyResult,
horizon_count: 1,
chunks: [{ text: 'Low-confidence chunk.', score: 0.5, source: 'lore', author: '', timestamp: '' }],
};
const output = formatNPCMemory(result);
expect(output).not.toContain('Low-confidence chunk.');
});
it('truncates chunks longer than 200 characters', () => {
const longText = 'A'.repeat(300);
const result: NPCQueryResult = {
...emptyResult,
horizon_count: 1,
chunks: [{ text: longText, score: 0.9, source: 'lore', author: '', timestamp: '' }],
};
const output = formatNPCMemory(result);
expect(output).toContain('…');
expect(output).not.toContain('A'.repeat(300));
});
it('includes at most 3 chunks', () => {
const chunks = Array.from({ length: 5 }, (_, i) => ({
text: `Chunk ${i} content here.`,
score: 0.9,
source: 'lore' as const,
author: '',
timestamp: '',
}));
const result: NPCQueryResult = { ...emptyResult, horizon_count: 5, chunks };
const output = formatNPCMemory(result);
const matchCount = (output.match(/Chunk \d content here\./g) ?? []).length;
expect(matchCount).toBeLessThanOrEqual(3);
});
});
// Build a GraphMCP JSON-RPC envelope whose tool-result text is JSON.stringify(payload).
// callTool parses json.result.content[0].text, so this lets us feed arbitrary
// tool-result shapes to the public functions.
function rpcEnvelope(payload: unknown): Response {
return {
ok: true,
status: 200,
json: async () => ({
jsonrpc: '2.0',
result: { content: [{ type: 'text', text: JSON.stringify(payload) }] },
}),
} as unknown as Response;
}
describe('semanticSearch response normalization', () => {
afterEach(() => vi.unstubAllGlobals());
// Regression: /encounter generate crashed with "Cannot read properties of
// undefined (reading 'length')" when GraphMCP returned a success response
// whose `chunks` field was missing/null. The `.catch(() => ({ chunks: [] }))`
// at the call site only covers rejection, not a wrong-shape success.
it('returns [] when chunks is null (no crash on .length)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope({ chunks: null })));
const result = await semanticSearch('q', 5);
expect(result.chunks).toEqual([]);
});
it('returns [] when the response has no chunks field', async () => {
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope({ results: [{ content: 'x' }] })));
const result = await semanticSearch('q', 5);
expect(result.chunks).toEqual([]);
});
it('returns [] when GraphMCP returns null', async () => {
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope(null)));
const result = await semanticSearch('q', 5);
expect(result.chunks).toEqual([]);
});
it('accepts a bare array as the chunks', async () => {
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope([{ content: 'a', score: 1 }])));
const result = await semanticSearch('q', 5);
expect(result.chunks).toHaveLength(1);
expect(result.chunks[0].content).toBe('a');
});
it('preserves a well-formed { chunks: [...] } response', async () => {
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope({
chunks: [{ content: 'a', score: 0.9 }, { content: 'b', score: 0.8 }],
})));
const result = await semanticSearch('q', 5);
expect(result.chunks).toHaveLength(2);
});
});
describe('listEncounters response normalization', () => {
afterEach(() => vi.unstubAllGlobals());
it('returns [] for a non-array response instead of leaking the wrong shape', async () => {
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope({ encounters: [{ id: '1' }] })));
const result = await listEncounters(5);
expect(result).toEqual([]);
});
it('returns the array when GraphMCP returns one', async () => {
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope([{
id: '1', title: 't', location: 'l', timestamp: '', summary: 's',
}])));
const result = await listEncounters(5);
expect(result).toHaveLength(1);
});
});
// Regression: the live GraphMCP backend returns chunks shaped as
// { text, score, source, author, timestamp, msgID } — NOT { content, ... }.
// The client's SemanticChunk type and its callers (encounter.ts handleGenerate
// does `c.content.slice(...)`, mentionHandler reads `c.content`) expect
// `.content`. Without boundary mapping, `.content` is undefined and
// `c.content.slice` throws the same "Cannot read properties of undefined"
// class as the loreResult.chunks crash. semanticSearch must map text→content.
describe('semanticSearch chunk field mapping (live shape: text, not content)', () => {
afterEach(() => vi.unstubAllGlobals());
it('maps the live `text` field to the declared `content` field', async () => {
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope([{
text: 'tell me about Mardonar',
score: 0.84,
source: 'message',
author: 'sirhaxolot',
timestamp: '2026-05-26T03:06:18Z',
msgID: '1508667570604081356',
}])));
const result = await semanticSearch('q', 5);
expect(result.chunks).toHaveLength(1);
expect(result.chunks[0].content).toBe('tell me about Mardonar');
expect(result.chunks[0].score).toBe(0.84);
expect(result.chunks[0].source).toBe('message');
});
it('falls back to `content` when a chunk uses the declared field name', async () => {
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope([{ content: 'legacy', score: 0.5 }])));
const result = await semanticSearch('q', 5);
expect(result.chunks[0].content).toBe('legacy');
});
it('coerces a chunk missing both text and content to an empty string (no crash)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope([{ score: 0.5 }])));
const result = await semanticSearch('q', 5);
expect(result.chunks[0].content).toBe('');
expect(result.chunks[0].score).toBe(0.5);
});
});
// Regression: the live GraphMCP backend returns `chunks: null` (and sometimes
// `graph_context: null`) for NPCs with no prior memory. The raw
// `as NPCQueryResult` cast let null leak through; the contract is arrays.
describe('queryAsNPC null-array normalization', () => {
afterEach(() => vi.unstubAllGlobals());
it('coerces null chunks and graph_context to empty arrays', async () => {
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope({
npc: 'miriam-merchant-mardonar',
tier: 'local',
horizon_count: 0,
chunks: null,
graph_context: null,
})));
const result = await queryAsNPC('miriam-merchant-mardonar', 'recent events', 5);
expect(Array.isArray(result.chunks)).toBe(true);
expect(result.chunks).toEqual([]);
expect(Array.isArray(result.graph_context)).toBe(true);
expect(result.npc).toBe('miriam-merchant-mardonar');
expect(result.horizon_count).toBe(0);
});
});