Skip to content

Commit 05fc4ab

Browse files
hustccgemini-code-assist[bot]Copilot
authored
test: add testcases and coverage (#4)
* test: add testcases and coverage * docs: add HF_ENDPOINT tip * Update test/storage/store.test.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update test/utils/sample.test.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * fix: tmp file leak * fix: increase test timeouts and prevent concurrent model downloads in CI --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 8f705a2 commit 05fc4ab

25 files changed

Lines changed: 793 additions & 188 deletions

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# @antv/context
22

3+
[![Build](https://github.com/antvis/context/actions/workflows/build.yml/badge.svg)](https://github.com/antvis/context/actions/workflows/build.yml)
4+
[![npm version](https://img.shields.io/npm/v/@antv/context)](https://www.npmjs.com/package/@antv/context)
5+
[![npm downloads](https://img.shields.io/npm/dm/@antv/context)](https://www.npmjs.com/package/@antv/context)
6+
[![License](https://img.shields.io/npm/l/@antv/context)](./LICENSE)
7+
38
A local context retrieval library that enables semantic search over your documentation. It loads documents (Markdown, JSON, Text), vectorizes them using [Transformers.js](https://huggingface.co/transformers.js), and stores vectors locally in `.zvec` files for fast semantic querying.
49

510
> [!TIP]
@@ -20,6 +25,12 @@ A local context retrieval library that enables semantic search over your documen
2025
npm install @antv/context
2126
```
2227

28+
> [!TIP]
29+
> If you encounter model download timeout when first creating a Context, set the environment variable:
30+
> ```bash
31+
> HF_ENDPOINT=https://hf-mirror.com node your-script.js
32+
> ```
33+
2334
```typescript
2435
import { Context } from '@antv/context';
2536
@@ -48,7 +59,7 @@ await ctx.close();
4859
| `vectorsDir` | `string` | `.context/vectors` | Directory to store vector files |
4960
| `basePath` | `string` | `process.cwd()` | Base path for resolving document IDs. Set for cross-machine consistent IDs. |
5061
| `onProgress` | `(phase, detail) => void` || Progress callback for `load()` phases: `'load'``'embed'``'insert'`. |
51-
| `queryExpansion` | `QueryExpansionOptions | false` | `false` (no-op) | Query expansion with user-provided synonym map. `false` disables. Without `synonyms`, expansion is a no-op. |
62+
| queryExpansion | QueryExpansionOptions | false | false | Query expansion with user-provided synonym map. false disables. Without synonyms, expansion is a no-op. |
5263
| `ftsFields` | `string[]` | `['content']` | Fields to index for Full Text Search in hybrid mode |
5364
| `ftsFieldWeights` | `Record<string, number>` | `{ content: 1 }` | Per-field boost weights for FTS text path. Higher = more influence. |
5465
| `rankConstant` | `number` | `60` | RRF rank constant for hybrid search fusion. Lower = "winner-takes-all", higher = more even. |
@@ -96,7 +107,7 @@ Document IDs are derived from file paths relative to `basePath` for cross-machin
96107
| Parameter | Type | Description |
97108
|-----------|------|-------------|
98109
| `library` | `string` | Library name for organizing documents |
99-
| `pattern` | `string | string[]` | Glob pattern(s) matching files to load |
110+
| `pattern` | `string \| string[]` | Glob pattern(s) matching files to load |
100111
101112
```typescript
102113
await ctx.load('g2', './docs/**/*.md');

src/embedder/embedder.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,32 @@ type TransformersPipeline = (texts: string[], options: Record<string, unknown>)
99

1010
export class Embedder {
1111
private static pipeline: TransformersPipeline | null = null;
12+
private static pipelinePromise: Promise<TransformersPipeline> | null = null;
1213

1314
readonly dimensions = DEFAULT_DIMENSIONS;
1415

1516
private static async getPipeline(): Promise<TransformersPipeline> {
1617
if (Embedder.pipeline) return Embedder.pipeline;
1718

18-
const mod = await import('@huggingface/transformers');
19-
const hfEndpoint = process.env.HF_ENDPOINT;
20-
if (hfEndpoint && (mod as any).env) {
21-
(mod as any).env.remoteHost = hfEndpoint;
19+
if (!Embedder.pipelinePromise) {
20+
Embedder.pipelinePromise = (async () => {
21+
const mod = await import('@huggingface/transformers');
22+
const hfEndpoint = process.env.HF_ENDPOINT;
23+
if (hfEndpoint && (mod as any).env) {
24+
(mod as any).env.remoteHost = hfEndpoint;
25+
}
26+
27+
const pipe = await mod.pipeline('feature-extraction', DEFAULT_MODEL_ID) as TransformersPipeline;
28+
Embedder.pipeline = pipe;
29+
return Embedder.pipeline;
30+
})().catch((err) => {
31+
Embedder.pipeline = null;
32+
Embedder.pipelinePromise = null;
33+
throw err;
34+
});
2235
}
2336

24-
const pipe = await mod.pipeline('feature-extraction', DEFAULT_MODEL_ID) as TransformersPipeline;
25-
Embedder.pipeline = pipe;
26-
return Embedder.pipeline;
37+
return Embedder.pipelinePromise;
2738
}
2839

2940
async embed(text: string): Promise<number[]> {

src/utils/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
export { pathToId } from './doc';
22
export { computeContentHash } from './hash';
33
export { safeJsonParse } from './common';
4-
export { loadSampleText } from './sample';
5-
export { isCJK, detectLanguage, tokenizerForLanguage, detectTokenizer } from './tokenizer';
6-
export type { LanguageHint } from './tokenizer';
7-
export { containsCJK } from './str';
4+
export { loadSampleText, selectSampleFiles } from './sample';
5+
export { isCJK, containsCJK, detectLanguage, tokenizerForLanguage, detectTokenizer } from './tokenizer';
6+
export type { LanguageHint } from './tokenizer';

src/utils/sample.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as fs from 'fs';
33
/**
44
* Select a representative sample of files for tokenizer detection.
55
*/
6-
function selectSampleFiles(files: string[], maxCount: number): string[] {
6+
export function selectSampleFiles(files: string[], maxCount: number): string[] {
77
if (files.length <= maxCount) return files;
88

99
const result: string[] = [];
@@ -27,19 +27,15 @@ function selectSampleFiles(files: string[], maxCount: number): string[] {
2727
export async function loadSampleText(files: string[], sampleCount = 5): Promise<string | undefined> {
2828
if (files.length === 0) return undefined;
2929

30-
try {
31-
const sampleFiles = selectSampleFiles(files, sampleCount);
32-
const samples = await Promise.allSettled(
33-
sampleFiles.map((f) => fs.promises.readFile(f, 'utf-8')),
34-
);
35-
const validSamples = samples
36-
.filter((r): r is PromiseFulfilledResult<string> => r.status === 'fulfilled')
37-
.map((r) => r.value);
38-
if (validSamples.length > 0) {
39-
return validSamples.join('\n');
40-
}
41-
} catch {
42-
// Sample failure is non-fatal
30+
const sampleFiles = selectSampleFiles(files, sampleCount);
31+
const samples = await Promise.allSettled(
32+
sampleFiles.map((f) => fs.promises.readFile(f, 'utf-8')),
33+
);
34+
const validSamples = samples
35+
.filter((r): r is PromiseFulfilledResult<string> => r.status === 'fulfilled')
36+
.map((r) => r.value);
37+
if (validSamples.length > 0) {
38+
return validSamples.join('\n');
4339
}
4440
return undefined;
4541
}

src/utils/str.ts

Lines changed: 0 additions & 7 deletions
This file was deleted.

src/utils/tokenizer.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ export function isCJK(ch: string): boolean {
2525
);
2626
}
2727

28+
/**
29+
* Check whether a string contains any CJK characters.
30+
*/
31+
export function containsCJK(text: string): boolean {
32+
for (const ch of text) {
33+
if (isCJK(ch)) return true;
34+
}
35+
return false;
36+
}
37+
2838
/**
2939
* Detect the dominant language category of a text sample.
3040
*

test/context.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,30 @@ describe('Context', () => {
4141
});
4242

4343
describe('load', () => {
44+
it('should call onProgress callback during load', async () => {
45+
const progressCalls: { phase: string; detail: { loaded: number; total: number } }[] = [];
46+
const progressDir = TEST_DIR + '-progress';
47+
48+
const ctxWithProgress = await Context.create({
49+
vectorsDir: progressDir,
50+
onProgress: (phase, detail) => {
51+
progressCalls.push({ phase, detail });
52+
},
53+
});
54+
55+
await ctxWithProgress.load('md', path.join(FIXTURES_DIR, '*.md'));
56+
57+
expect(progressCalls.length).toBeGreaterThan(0);
58+
expect(progressCalls.map(c => c.phase)).toContain('load');
59+
60+
await ctxWithProgress.close();
61+
62+
// Cleanup
63+
if (fs.existsSync(progressDir)) {
64+
fs.rmSync(progressDir, { recursive: true, force: true });
65+
}
66+
});
67+
4468
it('should load markdown files', async () => {
4569
await ctx.load('md', path.join(FIXTURES_DIR, 'getting-started.md'));
4670

test/embedder/embedder.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { Embedder } from '../../src/embedder/embedder';
3+
4+
describe('Embedder', () => {
5+
it('should have default dimensions of 512', () => {
6+
const embedder = new Embedder();
7+
expect(embedder.dimensions).toBe(512);
8+
});
9+
10+
it('should have static pipeline property', () => {
11+
expect(Embedder.pipeline).toBeNull();
12+
});
13+
14+
it('should be instantiable', () => {
15+
const embedder = new Embedder();
16+
expect(embedder).toBeInstanceOf(Embedder);
17+
});
18+
});

test/expander/expand.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { expand } from '../../src/expander';
3+
4+
describe('expand', () => {
5+
const queryExpansion = {
6+
synonyms: {
7+
'折线图': ['line chart', '折线'],
8+
'tooltip': ['提示框', '提示', 'hover'],
9+
'animation': ['动效', 'animate', 'transition'],
10+
'config': ['配置', 'configuration', '设置'],
11+
},
12+
};
13+
14+
it('should expand CN chart type to EN equivalent', () => {
15+
const result = expand('折线图', queryExpansion);
16+
expect(result).toContain('line chart');
17+
});
18+
19+
it('should expand EN term to CN equivalent', () => {
20+
const result = expand('tooltip', queryExpansion);
21+
expect(result).toContain('提示框');
22+
});
23+
24+
it('should not duplicate terms already in query', () => {
25+
const result = expand('tooltip 提示框', queryExpansion);
26+
const promptCount = result.split('提示框').length - 1;
27+
expect(promptCount).toBe(1);
28+
});
29+
30+
it('should expand multiple terms in one query', () => {
31+
const result = expand('tooltip config', queryExpansion);
32+
expect(result).toContain('提示框');
33+
expect(result).toContain('配置');
34+
});
35+
36+
it('should preserve original query text', () => {
37+
const result = expand('animation settings', queryExpansion);
38+
expect(result.startsWith('animation settings')).toBe(true);
39+
});
40+
41+
it('should return original query when no synonyms match', () => {
42+
const result = expand('random unrelated terms', queryExpansion);
43+
expect(result).toBe('random unrelated terms');
44+
});
45+
46+
it('should handle empty query', () => {
47+
const result = expand('', queryExpansion);
48+
expect(result).toBe('');
49+
});
50+
51+
it('should return original query with no synonyms', () => {
52+
const result = expand('tooltip configuration');
53+
expect(result).toBe('tooltip configuration');
54+
});
55+
56+
it('should return original query with empty synonyms', () => {
57+
const result = expand('tooltip configuration', {});
58+
expect(result).toBe('tooltip configuration');
59+
});
60+
61+
it('should return query unchanged when queryExpansion is false', () => {
62+
const result = expand('折线图配置', false);
63+
expect(result).toBe('折线图配置');
64+
});
65+
66+
it('should handle CJK terms with substring match', () => {
67+
// Line 14: containsCJK(term) returns true, so substring match is used
68+
const result = expand('折线图', queryExpansion);
69+
expect(result).toContain('折线');
70+
});
71+
72+
it('should handle term at start of query', () => {
73+
// Line 18-28: while loop with word boundary at start
74+
const result = expand('tooltip chart', queryExpansion);
75+
expect(result).toContain('提示框');
76+
});
77+
78+
it('should handle term at end of query', () => {
79+
// Test term matching at end of query
80+
const result = expand('chart tooltip', queryExpansion);
81+
expect(result).toContain('提示框');
82+
});
83+
84+
it('should handle term in middle of query', () => {
85+
const result = expand('show tooltip here', queryExpansion);
86+
expect(result).toContain('提示框');
87+
});
88+
89+
it('should not add synonym already in query', () => {
90+
// Line 45: containsTerm check prevents duplication
91+
const result = expand('tooltip 提示框', queryExpansion);
92+
const parts = result.split(' ');
93+
const promptCount = parts.filter(p => p === '提示框').length;
94+
expect(promptCount).toBe(1);
95+
});
96+
97+
it('should handle synonyms with already added terms', () => {
98+
// Multiple terms matching same synonym
99+
const result = expand('折线图 折线', queryExpansion);
100+
expect(result).toContain('line chart');
101+
});
102+
103+
it('should handle term at word boundary returning true immediately', () => {
104+
// This tests the path where containsTerm finds a match and returns true (line 26)
105+
// Direct term at word boundary
106+
const result = expand('test tooltip config', queryExpansion);
107+
expect(result).toContain('提示');
108+
});
109+
110+
it('should not match partial Latin substring in word', () => {
111+
// This tests the path where term is found but not at word boundaries (lines 27-29)
112+
// Searching for "config" in "configured" - gets past include check but not boundary check
113+
const testExpansion = {
114+
synonyms: { 'config': ['setting'] }
115+
};
116+
const result = expand('configured', testExpansion);
117+
// "config" is part of "configured", not a standalone word - should not match
118+
expect(result).toBe('configured');
119+
});
120+
121+
it('should handle term found but never at word boundary', () => {
122+
// This tests line 29-30: term found but all occurrences fail boundary check
123+
// Using a term that appears only as part of a longer word
124+
const testExpansion = {
125+
synonyms: { 'abc': ['xyz'] }
126+
};
127+
// "abc" does NOT appear in this text at all - so line 11 returns false early
128+
// Need text that contains "abc" but never as a standalone word
129+
const result = expand('xabcy', testExpansion);
130+
// "abc" is in "xabcy" but not at word boundary (surrounded by letters)
131+
// This will go through the loop, never find a boundary match, then return false
132+
// This triggers lines 29-30
133+
expect(result).toBe('xabcy');
134+
});
135+
});

test/fixtures/docs/string.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"This is a JSON string value"

0 commit comments

Comments
 (0)