Files
wehub-resource-sync 979fb22d7c
CI / test (20, macos-latest) (push) Waiting to run
CI / test (20, ubuntu-latest) (push) Waiting to run
CI / test (22, macos-latest) (push) Waiting to run
CI / test (22, ubuntu-latest) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:01:18 +08:00

294 lines
9.1 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import { SearchIndex } from "../src/state/search-index.js";
import { segmentCjk } from "../src/state/cjk-segmenter.js";
import type { CompressedObservation } from "../src/types.js";
function makeObs(
overrides: Partial<CompressedObservation> = {},
): CompressedObservation {
return {
id: "obs_1",
sessionId: "ses_1",
timestamp: new Date().toISOString(),
type: "file_edit",
title: "Edit auth middleware",
subtitle: "JWT validation",
facts: ["Added token check"],
narrative: "Modified the auth middleware to validate JWT tokens",
concepts: ["authentication", "jwt"],
files: ["src/middleware/auth.ts"],
importance: 7,
...overrides,
};
}
describe("SearchIndex", () => {
let index: SearchIndex;
beforeEach(() => {
index = new SearchIndex();
});
it("starts empty", () => {
expect(index.size).toBe(0);
});
it("adds and finds observations", () => {
index.add(makeObs());
expect(index.size).toBe(1);
const results = index.search("auth");
expect(results.length).toBe(1);
expect(results[0].obsId).toBe("obs_1");
});
it("returns empty for no matches", () => {
index.add(makeObs());
expect(index.search("database")).toEqual([]);
});
it("scores exact matches higher than prefix matches", () => {
index.add(
makeObs({
id: "obs_exact",
title: "redis cache",
narrative: "Set up redis caching layer",
concepts: ["redis"],
facts: ["Added redis"],
files: ["src/redis.ts"],
}),
);
index.add(
makeObs({
id: "obs_prefix",
title: "redistool handler",
narrative: "Set up redistool for ops",
concepts: ["redistool"],
facts: ["Added redistool"],
files: ["src/redistool.ts"],
}),
);
const results = index.search("redis");
const exact = results.find((r) => r.obsId === "obs_exact");
const prefix = results.find((r) => r.obsId === "obs_prefix");
expect(exact).toBeDefined();
expect(prefix).toBeDefined();
expect(exact!.score).toBeGreaterThanOrEqual(prefix!.score);
});
it("respects limit", () => {
for (let i = 0; i < 30; i++) {
index.add(makeObs({ id: `obs_${i}`, title: `auth feature ${i}` }));
}
expect(index.search("auth", 5).length).toBe(5);
});
it("clears the index", () => {
index.add(makeObs());
index.clear();
expect(index.size).toBe(0);
expect(index.search("auth")).toEqual([]);
});
// Regression coverage: deleted docs must not keep occupying result
// slots after remove(). Without remove(), a limit-capped search can
// return fewer live results than requested because the slot is held
// by a doc that no longer exists in storage.
describe("remove", () => {
it("drops a removed doc from search results", () => {
index.add(makeObs({ id: "obs_a", title: "jose for JWT" }));
index.add(
makeObs({ id: "obs_b", title: "JWT tokens expire after 30 minutes" }),
);
expect(index.search("jose jwt", 1)[0].obsId).toBe("obs_a");
index.remove("obs_a");
// With limit=1, the survivor must now surface instead of getting
// masked by the deleted doc's slot.
const after = index.search("jose jwt", 1);
expect(after).toHaveLength(1);
expect(after[0].obsId).toBe("obs_b");
expect(index.size).toBe(1);
});
it("is a no-op for unknown ids", () => {
index.add(makeObs({ id: "obs_a" }));
expect(() => index.remove("does_not_exist")).not.toThrow();
expect(index.size).toBe(1);
expect(index.search("auth")).toHaveLength(1);
});
it("cleans up empty posting lists and the prefix cache", () => {
// unique_concept_xyz appears only in obs_a. After removing obs_a,
// a prefix search for "unique_" must NOT surface it from the
// sortedTerms cache.
index.add(
makeObs({
id: "obs_a",
concepts: ["unique_concept_xyz"],
}),
);
index.add(makeObs({ id: "obs_b", title: "completely different" }));
// Prime the sortedTerms cache.
expect(index.search("unique_")).toHaveLength(1);
index.remove("obs_a");
expect(index.search("unique_concept_xyz")).toEqual([]);
expect(index.search("unique_")).toEqual([]);
});
it("keeps scoring consistent after add/remove/add cycles", () => {
// totalDocLength bookkeeping desync would skew BM25 normalization
// (docLen / avgDocLen). Round-trip add → remove → re-add and
// confirm size + retrievability hold.
const obs = makeObs({ id: "obs_a" });
index.add(obs);
expect(index.size).toBe(1);
index.remove("obs_a");
expect(index.size).toBe(0);
index.add(obs);
expect(index.size).toBe(1);
expect(index.search("auth")[0].obsId).toBe("obs_a");
});
it("survives serialize/deserialize after removes", () => {
index.add(makeObs({ id: "obs_a", title: "alpha doc" }));
index.add(makeObs({ id: "obs_b", title: "beta doc" }));
index.remove("obs_a");
const restored = SearchIndex.deserialize(index.serialize());
expect(restored.size).toBe(1);
expect(restored.search("alpha")).toEqual([]);
expect(restored.search("beta")).toHaveLength(1);
});
});
it("returns empty for empty query", () => {
index.add(makeObs());
expect(index.search("")).toEqual([]);
});
it("searches across multiple fields", () => {
index.add(
makeObs({ id: "obs_file", title: "something", files: ["auth.ts"] }),
);
expect(index.search("auth").length).toBe(1);
});
it("handles multiple query terms", () => {
index.add(
makeObs({
id: "obs_both",
title: "redis cache",
narrative: "Set up redis and cache layer",
concepts: ["redis", "cache"],
facts: ["Added caching"],
files: ["src/cache.ts"],
}),
);
index.add(
makeObs({
id: "obs_one",
title: "redis only",
narrative: "Set up redis connection",
concepts: ["redis"],
facts: ["Added redis"],
files: ["src/redis.ts"],
}),
);
const results = index.search("redis cache");
expect(results[0].obsId).toBe("obs_both");
expect(results[0].score).toBeGreaterThan(results[1].score);
});
it("indexes and finds non-ASCII (Greek) text", () => {
index.add(
makeObs({
id: "obs_greek",
title: "Προβολή μνήμης",
narrative: "Δοκιμάζουμε αναζήτηση σε ελληνικά",
concepts: ["δοκιμή", "μνήμη"],
}),
);
const results = index.search("μνήμη");
expect(results.length).toBe(1);
expect(results[0].obsId).toBe("obs_greek");
});
it("tokenizes mixed ASCII and non-ASCII (Greek) queries", () => {
index.add(
makeObs({
id: "obs_mixed",
title: "JWT middleware ρύθμιση",
narrative: "Configured JWT with ελληνικά σχόλια",
concepts: ["auth", "jwt", "ρύθμιση"],
}),
);
const results = index.search("JWT ρύθμιση");
expect(results.length).toBe(1);
expect(results[0].obsId).toBe("obs_mixed");
});
it("segments Chinese (Han) text into words", () => {
index.add(
makeObs({
id: "obs_zh",
title: "项目记忆存储",
narrative: "我们正在测试中文分词",
concepts: ["项目", "记忆"],
}),
);
const results = index.search("项目");
expect(results.length).toBeGreaterThan(0);
const hit = results.find((r) => r.obsId === "obs_zh");
expect(hit).toBeDefined();
expect(hit!.score).toBeGreaterThan(0);
});
it("segments Japanese (kana + kanji) text into words", () => {
index.add(
makeObs({
id: "obs_ja",
title: "プロジェクト記憶",
narrative: "日本語の分かち書きをテストしています",
concepts: ["プロジェクト", "記憶"],
}),
);
const results = index.search("プロジェクト");
expect(results.length).toBeGreaterThan(0);
const hit = results.find((r) => r.obsId === "obs_ja");
expect(hit).toBeDefined();
expect(hit!.score).toBeGreaterThan(0);
});
it("segments Korean (Hangul) syllable blocks into words", () => {
index.add(
makeObs({
id: "obs_ko",
title: "프로젝트 메모리 저장소",
narrative: "한국어 검색을 테스트합니다",
concepts: ["프로젝트", "메모리"],
}),
);
const results = index.search("메모리");
expect(results.length).toBeGreaterThan(0);
const hit = results.find((r) => r.obsId === "obs_ko");
expect(hit).toBeDefined();
expect(hit!.score).toBeGreaterThan(0);
});
it("preserves source order across mixed CJK and non-CJK runs", () => {
expect(segmentCjk("hello 项目 world")).toEqual(["hello", "项目", "world"]);
expect(segmentCjk("abc 메모리 def 项目 ghi")).toEqual([
"abc",
"메모리",
"def",
"项目",
"ghi",
]);
expect(segmentCjk("leading 项目")).toEqual(["leading", "项目"]);
expect(segmentCjk("项目 trailing")).toEqual(["项目", "trailing"]);
});
});