aaronsb_obsidian-mcp-plugin/tests/fuzzy-match.test.ts
Aaron Bockelie 535dfd1c53 fix(mcp): stop debug route shadowing the GET /mcp SSE stream + two-row Levenshtein
Reimplemented from PR #126 (djsplice, who also reported #125), reduced to
the parts that are verified-clean. The worker-offload half of #126 is split
out to a tracking issue (inert as wired + correctness divergences — see PR
discussion / the follow-up issue).

SSE route deconfliction
  In @modelcontextprotocol/sdk@1.29.0 (the pinned version), `GET /mcp` is
  the standalone server->client SSE stream for server-initiated messages —
  verified in webStandardStreamableHttp.js:handleGetRequest (opens
  `_GET_stream`, Content-Type text/event-stream, gated on
  Accept: text/event-stream + session + protocol version). A debug route
  returning application/json on `GET /mcp` shadowed it, so any client that
  opens the standalone stream (mcp-remote-class bridges — #128's logs show
  one in use here) received JSON instead of an SSE stream and treated it as
  failed. Moved debug to `GET /mcp-info`; `GET /mcp` and `POST /mcp` both
  reach handleMCPRequest. Unlike #126's `app.all('/mcp')`, GET/POST are
  registered individually so the existing explicit `app.delete('/mcp')`
  session-close handler is preserved.

  Scope note: this restores the server-initiated-notification channel (a
  real latent defect on its own). It is NOT claimed to fix #128 — per the
  #190 investigation that is a separate SDK-1.29 compat-init problem — and
  #125's "SSE reconnection loop" is the reporter's un-reproduced diagnosis.

Two-row Levenshtein
  fuzzy-match.ts rewritten from a full (m+1)x(n+1) matrix to a two-row
  formulation (O(n) memory, no per-line array-of-arrays allocation) plus
  length heuristics and a 0.95 early-exit. Pure CPU/memory win on the
  main-thread edit.window path; behaviour-preserving. Adds the first
  fuzzy-match tests (classic Levenshtein distances, symmetry,
  heuristic-preservation).

build.yml: PR-status-comment step set continue-on-error so it cannot red an
otherwise-green build on fork/limited-token PRs.

Co-authored-by: djsplice <barrows.jeff@gmail.com>
2026-05-18 21:05:22 -05:00

67 lines
2.4 KiB
TypeScript

import { calculateSimilarity, findFuzzyMatches } from '../src/utils/fuzzy-match';
// Guards the two-row Levenshtein rewrite (#126 / ADR-104). The optimization
// must not change matching *outcomes* — only memory/CPU. These pin known
// Levenshtein distances so a regression in the row-swap logic is caught.
describe('calculateSimilarity (two-row Levenshtein)', () => {
test('identical strings score 1', () => {
expect(calculateSimilarity('hello world', 'hello world')).toBe(1);
});
test('both empty score 1', () => {
expect(calculateSimilarity('', '')).toBe(1);
});
test('one empty scores 0', () => {
expect(calculateSimilarity('', 'abc')).toBe(0);
expect(calculateSimilarity('abc', '')).toBe(0);
});
test('kitten/sitting → distance 3 of max-len 7', () => {
// classic Levenshtein example: distance 3
expect(calculateSimilarity('kitten', 'sitting')).toBeCloseTo(1 - 3 / 7, 6);
});
test('flaw/lawn → distance 2 of max-len 4', () => {
expect(calculateSimilarity('flaw', 'lawn')).toBeCloseTo(1 - 2 / 4, 6);
});
test('is case-insensitive', () => {
expect(calculateSimilarity('Hello', 'hello')).toBe(1);
});
test('is symmetric (row order must not matter after the rewrite)', () => {
const a = 'the quick brown fox';
const b = 'the quikc brwn fx';
expect(calculateSimilarity(a, b)).toBeCloseTo(calculateSimilarity(b, a), 9);
});
});
describe('findFuzzyMatches', () => {
const content = [
'The quick brown fox jumps',
'over the lazy dog',
'completely unrelated text here',
].join('\n');
test('exact substring is a similarity-1 match with correct line number', () => {
const m = findFuzzyMatches(content, 'lazy dog', 0.7);
expect(m.length).toBeGreaterThan(0);
expect(m[0].similarity).toBe(1.0);
expect(m[0].lineNumber).toBe(2);
});
test('near match above threshold is found despite the heuristic skips', () => {
const m = findFuzzyMatches(content, 'quick brown fox', 0.6);
expect(m.some(x => x.lineNumber === 1)).toBe(true);
});
test('unrelated query below threshold yields nothing', () => {
expect(findFuzzyMatches(content, 'xylophone zeppelin quasar', 0.7)).toEqual([]);
});
test('empty / whitespace search returns no matches', () => {
expect(findFuzzyMatches(content, '', 0.7)).toEqual([]);
expect(findFuzzyMatches(content, ' ', 0.7)).toEqual([]);
});
});