@adeu/mcp-server 1.15.1 → 1.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +118 -51
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +217 -173
- package/src/response-builders.ts +118 -50
- package/src/response_builders.test.ts +266 -0
package/src/response-builders.ts
CHANGED
|
@@ -214,12 +214,11 @@ export function build_search_response(
|
|
|
214
214
|
search_query: string,
|
|
215
215
|
search_regex: boolean,
|
|
216
216
|
search_case_sensitive: boolean,
|
|
217
|
-
page: number | string,
|
|
217
|
+
page: number | string | undefined,
|
|
218
218
|
file_path: string,
|
|
219
219
|
): ToolResult {
|
|
220
220
|
const [body] = split_structural_appendix(text);
|
|
221
|
-
const escapeRegExp = (s: string) =>
|
|
222
|
-
s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
221
|
+
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
223
222
|
const flags = search_case_sensitive ? "g" : "gi";
|
|
224
223
|
const patternStr = search_regex ? search_query : escapeRegExp(search_query);
|
|
225
224
|
|
|
@@ -230,54 +229,123 @@ export function build_search_response(
|
|
|
230
229
|
throw new Error(`Invalid regex pattern: ${e.message}`);
|
|
231
230
|
}
|
|
232
231
|
|
|
233
|
-
const
|
|
232
|
+
const allMatches = Array.from(body.matchAll(regex));
|
|
234
233
|
|
|
234
|
+
// Compute document pagination once — needed for both annotation and filtering.
|
|
235
|
+
const pag_res = paginate(body, "");
|
|
236
|
+
const page_offsets = pag_res.body_page_offsets;
|
|
237
|
+
const total_doc_pages = pag_res.total_pages;
|
|
238
|
+
|
|
239
|
+
// Resolve `page` parameter to either "all" or a concrete document-page number.
|
|
240
|
+
// Undefined → "all" (search across the whole document).
|
|
241
|
+
// "all" (case-insensitive) → "all".
|
|
242
|
+
// A positive integer N → filter matches to document page N.
|
|
243
|
+
// Anything else → hard error.
|
|
244
|
+
let filter_doc_page: number | null = null; // null means "all"
|
|
245
|
+
if (page !== undefined && page !== null) {
|
|
246
|
+
const pageStr = String(page).toLowerCase();
|
|
247
|
+
if (pageStr !== "all") {
|
|
248
|
+
const parsed = parseInt(pageStr, 10);
|
|
249
|
+
if (isNaN(parsed) || parsed < 1) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Invalid page value: \`${page}\`. Pass a positive integer to restrict the search to that document page, omit \`page\` to search all pages, or pass \`page='all'\` explicitly.`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (parsed > total_doc_pages) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`Document page ${parsed} is out of range — the document has ${total_doc_pages} page(s). In search mode, \`page\` filters matches to a specific document page; omit it or pass \`page='all'\` to search the whole document.`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
filter_doc_page = parsed;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Helper: which document page does an offset live on?
|
|
264
|
+
const pageOfOffset = (offset: number): number => {
|
|
265
|
+
let p = 1;
|
|
266
|
+
for (let j = 0; j < page_offsets.length; j++) {
|
|
267
|
+
if (offset >= page_offsets[j]) p = j + 1;
|
|
268
|
+
else break;
|
|
269
|
+
}
|
|
270
|
+
return p;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Apply the filter (if any), but keep a record of all pages that had hits
|
|
274
|
+
// so we can show a useful summary even when filtered.
|
|
275
|
+
const pagesWithHits = new Set<number>();
|
|
276
|
+
for (const m of allMatches) {
|
|
277
|
+
pagesWithHits.add(pageOfOffset(m.index!));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const matches =
|
|
281
|
+
filter_doc_page === null
|
|
282
|
+
? allMatches
|
|
283
|
+
: allMatches.filter((m) => pageOfOffset(m.index!) === filter_doc_page);
|
|
284
|
+
|
|
285
|
+
// --- Empty result ---
|
|
235
286
|
if (matches.length === 0) {
|
|
236
|
-
|
|
237
|
-
|
|
287
|
+
let body_msg: string;
|
|
288
|
+
if (filter_doc_page !== null) {
|
|
289
|
+
if (allMatches.length === 0) {
|
|
290
|
+
body_msg = `> **Search Results** — No matches found for query \`${search_query}\` in \`${basename(file_path)}\`.\n\nVerify your search spelling, or try setting \`search_case_sensitive\` to false or enabling \`search_regex\` if you used pattern wildcards.`;
|
|
291
|
+
} else {
|
|
292
|
+
const hitPages = Array.from(pagesWithHits).sort((a, b) => a - b);
|
|
293
|
+
body_msg = `> **Search Results** — No matches for \`${search_query}\` on document page ${filter_doc_page}.\n\nThe query DOES appear elsewhere in the document (${allMatches.length} match${allMatches.length !== 1 ? "es" : ""} on page${hitPages.length !== 1 ? "s" : ""} ${hitPages.join(", ")}). Omit \`page\` or pass \`page='all'\` to see them.`;
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
body_msg = `> **Search Results** — No matches found for query \`${search_query}\` in \`${basename(file_path)}\`.\n\nVerify your search spelling, or try setting \`search_case_sensitive\` to false or enabling \`search_regex\` if you used pattern wildcards.`;
|
|
297
|
+
}
|
|
298
|
+
const llm_content = `> **File Path:** \`${resolve(file_path)}\`\n\n${body_msg}`;
|
|
238
299
|
return {
|
|
239
300
|
content: [{ type: "text", text: llm_content }],
|
|
240
301
|
structuredContent: {
|
|
241
|
-
markdown:
|
|
302
|
+
markdown: body_msg,
|
|
242
303
|
title: `Search: ${basename(file_path)}`,
|
|
243
304
|
file_path: resolve(file_path),
|
|
244
305
|
},
|
|
245
306
|
};
|
|
246
307
|
}
|
|
247
308
|
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
const total_matches = matches.length;
|
|
251
|
-
const total_pages = Math.ceil(total_matches / 10);
|
|
252
|
-
|
|
253
|
-
let start_idx = 0;
|
|
254
|
-
let end_idx = total_matches;
|
|
255
|
-
let page_text = "all";
|
|
256
|
-
|
|
257
|
-
const pageStr = String(page).toLowerCase();
|
|
258
|
-
if (pageStr !== "all") {
|
|
259
|
-
const page_num = parseInt(pageStr, 10);
|
|
260
|
-
if (isNaN(page_num) || page_num < 1 || page_num > total_pages) {
|
|
261
|
-
throw new Error(`Page ${page} out of range (search has ${total_pages} pages).`);
|
|
262
|
-
}
|
|
263
|
-
start_idx = (page_num - 1) * 10;
|
|
264
|
-
end_idx = Math.min(start_idx + 10, total_matches);
|
|
265
|
-
page_text = `${page_num} of ${total_pages}`;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const page_matches = matches.slice(start_idx, end_idx);
|
|
309
|
+
// --- Build the response ---
|
|
310
|
+
const ui_parts: string[] = [];
|
|
269
311
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
312
|
+
if (filter_doc_page !== null) {
|
|
313
|
+
ui_parts.push(
|
|
314
|
+
`> **Search Results** — Found ${matches.length} match${matches.length !== 1 ? "es" : ""} for \`${search_query}\` on document page ${filter_doc_page} of ${total_doc_pages} in \`${basename(file_path)}\`.`,
|
|
315
|
+
);
|
|
316
|
+
const otherPages = Array.from(pagesWithHits)
|
|
317
|
+
.filter((p) => p !== filter_doc_page)
|
|
318
|
+
.sort((a, b) => a - b);
|
|
319
|
+
if (otherPages.length > 0) {
|
|
320
|
+
ui_parts.push(
|
|
321
|
+
`> Additional matches exist on page${otherPages.length !== 1 ? "s" : ""} ${otherPages.join(", ")} — omit \`page\` or pass \`page='all'\` to see them.`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
ui_parts.push(
|
|
326
|
+
`> **Search Results** — Found ${matches.length} match${matches.length !== 1 ? "es" : ""} for \`${search_query}\` in \`${basename(file_path)}\`.`,
|
|
327
|
+
);
|
|
328
|
+
if (total_doc_pages > 1) {
|
|
329
|
+
// Build a per-page hit distribution: "p1: 3, p3: 1, p7: 12"
|
|
330
|
+
const counts = new Map<number, number>();
|
|
331
|
+
for (const m of allMatches) {
|
|
332
|
+
const p = pageOfOffset(m.index!);
|
|
333
|
+
counts.set(p, (counts.get(p) || 0) + 1);
|
|
334
|
+
}
|
|
335
|
+
const distribution = Array.from(counts.entries())
|
|
336
|
+
.sort((a, b) => a[0] - b[0])
|
|
337
|
+
.map(([p, n]) => `p${p}: ${n}`)
|
|
338
|
+
.join(", ");
|
|
339
|
+
ui_parts.push(
|
|
340
|
+
`> Distribution across ${total_doc_pages} document pages — ${distribution}. Pass \`page=N\` to filter to a specific document page.`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
277
343
|
}
|
|
278
344
|
|
|
345
|
+
// Per-match occurrence counts use the FULL match set, not the filtered one —
|
|
346
|
+
// this gives the LLM accurate global counts even when filtering.
|
|
279
347
|
const occurrences_map: Record<string, number> = {};
|
|
280
|
-
for (const m of
|
|
348
|
+
for (const m of allMatches) {
|
|
281
349
|
const matched_str = m[0];
|
|
282
350
|
occurrences_map[matched_str] = (occurrences_map[matched_str] || 0) + 1;
|
|
283
351
|
}
|
|
@@ -294,7 +362,10 @@ export function build_search_response(
|
|
|
294
362
|
if (m) {
|
|
295
363
|
const level = m[1].length;
|
|
296
364
|
if (level < current_level) {
|
|
297
|
-
let cleanHeading = m[2]
|
|
365
|
+
let cleanHeading = m[2]
|
|
366
|
+
.replace(/\*\*|__|[*_]/g, "")
|
|
367
|
+
.replace(/\{#[^}]+\}/g, "")
|
|
368
|
+
.trim();
|
|
298
369
|
if (cleanHeading.length > 80) {
|
|
299
370
|
cleanHeading = cleanHeading.substring(0, 80) + "...";
|
|
300
371
|
}
|
|
@@ -307,24 +378,19 @@ export function build_search_response(
|
|
|
307
378
|
return path.join(" > ");
|
|
308
379
|
}
|
|
309
380
|
|
|
310
|
-
let i =
|
|
311
|
-
for (const m of
|
|
381
|
+
let i = 1;
|
|
382
|
+
for (const m of matches) {
|
|
312
383
|
const matched_str = m[0];
|
|
313
384
|
const m_start = m.index!;
|
|
314
385
|
const m_end = m_start + matched_str.length;
|
|
315
|
-
|
|
316
|
-
let p_num = 1;
|
|
317
|
-
for (let j = 0; j < page_offsets.length; j++) {
|
|
318
|
-
if (m_start >= page_offsets[j]) {
|
|
319
|
-
p_num = j + 1;
|
|
320
|
-
} else {
|
|
321
|
-
break;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
386
|
+
const p_num = pageOfOffset(m_start);
|
|
324
387
|
|
|
325
388
|
const snippet_start = Math.max(0, m_start - 100);
|
|
326
389
|
const snippet_end = Math.min(body.length, m_end + 100);
|
|
327
|
-
const snippet =
|
|
390
|
+
const snippet =
|
|
391
|
+
body.substring(snippet_start, m_start) +
|
|
392
|
+
`**${matched_str}**` +
|
|
393
|
+
body.substring(m_end, snippet_end);
|
|
328
394
|
|
|
329
395
|
const snippet_lines = snippet
|
|
330
396
|
.split("\n")
|
|
@@ -342,7 +408,9 @@ export function build_search_response(
|
|
|
342
408
|
|
|
343
409
|
const count = occurrences_map[matched_str];
|
|
344
410
|
ui_parts.push(snippet_lines);
|
|
345
|
-
ui_parts.push(
|
|
411
|
+
ui_parts.push(
|
|
412
|
+
`*Occurrences:* This exact phrasing appears ${count} time${count !== 1 ? "s" : ""} in the document.`,
|
|
413
|
+
);
|
|
346
414
|
|
|
347
415
|
i++;
|
|
348
416
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// FILE: packages/mcp-server/src/response_builders.test.ts
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { build_search_response } from "./response-builders.js";
|
|
4
|
+
|
|
5
|
+
describe("build_search_response — page-as-document-filter semantics", () => {
|
|
6
|
+
// Small body: 3 matches all in one paragraph → all on document page 1.
|
|
7
|
+
function makeSmallBody(count: number): string {
|
|
8
|
+
const lines: string[] = ["# Section One"];
|
|
9
|
+
for (let i = 0; i < count; i++) {
|
|
10
|
+
lines.push(`Paragraph ${i} mentions the TARGET_PHRASE here.`);
|
|
11
|
+
}
|
|
12
|
+
return lines.join("\n\n");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Large body designed to span multiple document pages (PAGE_TARGET_CHARS=19000).
|
|
16
|
+
// Each filler paragraph is ~500 chars, so ~40 paragraphs per document page.
|
|
17
|
+
// We seed TARGET_PHRASE at known intervals so we can predict page distribution.
|
|
18
|
+
function makeMultiPageBody(): string {
|
|
19
|
+
const filler =
|
|
20
|
+
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.";
|
|
21
|
+
const blocks: string[] = [];
|
|
22
|
+
// ~50 paragraphs of filler before each marker, with TARGET_PHRASE injected at known places.
|
|
23
|
+
for (let i = 0; i < 200; i++) {
|
|
24
|
+
if (i === 10 || i === 11 || i === 12) {
|
|
25
|
+
blocks.push(`Paragraph ${i}: TARGET_PHRASE appears here.`);
|
|
26
|
+
} else if (i === 80) {
|
|
27
|
+
blocks.push(`Paragraph ${i}: TARGET_PHRASE appears here.`);
|
|
28
|
+
} else if (i === 150) {
|
|
29
|
+
blocks.push(`Paragraph ${i}: TARGET_PHRASE appears here.`);
|
|
30
|
+
} else {
|
|
31
|
+
blocks.push(`Paragraph ${i}: ${filler}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return blocks.join("\n\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it("page omitted with single-page doc: searches all, shows all matches", () => {
|
|
38
|
+
const body = makeSmallBody(3);
|
|
39
|
+
const res = build_search_response(
|
|
40
|
+
body,
|
|
41
|
+
"TARGET_PHRASE",
|
|
42
|
+
false,
|
|
43
|
+
true,
|
|
44
|
+
undefined,
|
|
45
|
+
"dummy.docx",
|
|
46
|
+
);
|
|
47
|
+
const text = res.content[0].text;
|
|
48
|
+
|
|
49
|
+
expect(text).toContain("Found 3 matches");
|
|
50
|
+
expect(text).toContain("### Match 1 (p1)");
|
|
51
|
+
expect(text).toContain("### Match 2 (p1)");
|
|
52
|
+
expect(text).toContain("### Match 3 (p1)");
|
|
53
|
+
// No clamp warning, no result-pagination hint.
|
|
54
|
+
expect(text).not.toContain("exceeds available result pages");
|
|
55
|
+
expect(text).not.toContain("Showing page");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("page='all' explicit: same behavior as omitting", () => {
|
|
59
|
+
const body = makeSmallBody(3);
|
|
60
|
+
const res = build_search_response(
|
|
61
|
+
body,
|
|
62
|
+
"TARGET_PHRASE",
|
|
63
|
+
false,
|
|
64
|
+
true,
|
|
65
|
+
"all",
|
|
66
|
+
"dummy.docx",
|
|
67
|
+
);
|
|
68
|
+
expect(res.content[0].text).toContain("Found 3 matches");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("page omitted with multi-page doc: shows distribution summary", () => {
|
|
72
|
+
const body = makeMultiPageBody();
|
|
73
|
+
const res = build_search_response(
|
|
74
|
+
body,
|
|
75
|
+
"TARGET_PHRASE",
|
|
76
|
+
false,
|
|
77
|
+
true,
|
|
78
|
+
undefined,
|
|
79
|
+
"dummy.docx",
|
|
80
|
+
);
|
|
81
|
+
const text = res.content[0].text;
|
|
82
|
+
|
|
83
|
+
expect(text).toContain("Found 5 matches");
|
|
84
|
+
expect(text).toContain("Distribution across");
|
|
85
|
+
expect(text).toMatch(/p\d+: \d+/);
|
|
86
|
+
expect(text).toContain("Pass `page=N` to filter");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("page=N as document-page filter: only returns matches on that page", () => {
|
|
90
|
+
const body = makeMultiPageBody();
|
|
91
|
+
|
|
92
|
+
// First find out which doc pages actually have hits by doing an "all" search.
|
|
93
|
+
const allRes = build_search_response(
|
|
94
|
+
body,
|
|
95
|
+
"TARGET_PHRASE",
|
|
96
|
+
false,
|
|
97
|
+
true,
|
|
98
|
+
"all",
|
|
99
|
+
"dummy.docx",
|
|
100
|
+
);
|
|
101
|
+
const allText = allRes.content[0].text;
|
|
102
|
+
// Extract the first hit page from the Match annotations.
|
|
103
|
+
const firstMatchPageMatch = allText.match(/### Match 1 \(p(\d+)\)/);
|
|
104
|
+
expect(firstMatchPageMatch).not.toBeNull();
|
|
105
|
+
const firstHitPage = parseInt(firstMatchPageMatch![1], 10);
|
|
106
|
+
|
|
107
|
+
// Now filter to that page.
|
|
108
|
+
const res = build_search_response(
|
|
109
|
+
body,
|
|
110
|
+
"TARGET_PHRASE",
|
|
111
|
+
false,
|
|
112
|
+
true,
|
|
113
|
+
firstHitPage,
|
|
114
|
+
"dummy.docx",
|
|
115
|
+
);
|
|
116
|
+
const text = res.content[0].text;
|
|
117
|
+
|
|
118
|
+
expect(text).toContain(`on document page ${firstHitPage}`);
|
|
119
|
+
// All shown matches must be on the filtered page.
|
|
120
|
+
const matchPageRegex = /### Match \d+ \(p(\d+)\)/g;
|
|
121
|
+
const matches = Array.from(text.matchAll(matchPageRegex));
|
|
122
|
+
expect(matches.length).toBeGreaterThan(0);
|
|
123
|
+
for (const m of matches) {
|
|
124
|
+
expect(parseInt(m[1], 10)).toBe(firstHitPage);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("page=N where N has no hits but query exists elsewhere: helpful message", () => {
|
|
129
|
+
const body = makeMultiPageBody();
|
|
130
|
+
// Find a page that has no hits. Multi-page body has hits on a few pages
|
|
131
|
+
// but several pages in between have none. Page 2 should be safe if hits
|
|
132
|
+
// are on early pages or much later pages.
|
|
133
|
+
// First, identify a hit-less page by checking the "all" output.
|
|
134
|
+
const allRes = build_search_response(
|
|
135
|
+
body,
|
|
136
|
+
"TARGET_PHRASE",
|
|
137
|
+
false,
|
|
138
|
+
true,
|
|
139
|
+
"all",
|
|
140
|
+
"dummy.docx",
|
|
141
|
+
);
|
|
142
|
+
const allText = allRes.content[0].text;
|
|
143
|
+
const hitPages = new Set<number>();
|
|
144
|
+
const matchPageRegex = /### Match \d+ \(p(\d+)\)/g;
|
|
145
|
+
for (const m of allText.matchAll(matchPageRegex)) {
|
|
146
|
+
hitPages.add(parseInt(m[1], 10));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Find the total page count from the distribution line.
|
|
150
|
+
const distMatch = allText.match(/Distribution across (\d+) document pages/);
|
|
151
|
+
expect(distMatch).not.toBeNull();
|
|
152
|
+
const totalPages = parseInt(distMatch![1], 10);
|
|
153
|
+
|
|
154
|
+
// Find a page with no hits.
|
|
155
|
+
let emptyPage: number | null = null;
|
|
156
|
+
for (let p = 1; p <= totalPages; p++) {
|
|
157
|
+
if (!hitPages.has(p)) {
|
|
158
|
+
emptyPage = p;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
expect(emptyPage).not.toBeNull();
|
|
163
|
+
|
|
164
|
+
const res = build_search_response(
|
|
165
|
+
body,
|
|
166
|
+
"TARGET_PHRASE",
|
|
167
|
+
false,
|
|
168
|
+
true,
|
|
169
|
+
emptyPage!,
|
|
170
|
+
"dummy.docx",
|
|
171
|
+
);
|
|
172
|
+
const text = res.content[0].text;
|
|
173
|
+
|
|
174
|
+
expect(text).toContain(
|
|
175
|
+
`No matches for \`TARGET_PHRASE\` on document page ${emptyPage}`,
|
|
176
|
+
);
|
|
177
|
+
expect(text).toContain("The query DOES appear elsewhere");
|
|
178
|
+
expect(text).toContain("Omit `page` or pass `page='all'`");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("page=N where N exceeds total document pages: hard error", () => {
|
|
182
|
+
const body = makeSmallBody(3); // Single-page document.
|
|
183
|
+
expect(() =>
|
|
184
|
+
build_search_response(
|
|
185
|
+
body,
|
|
186
|
+
"TARGET_PHRASE",
|
|
187
|
+
false,
|
|
188
|
+
true,
|
|
189
|
+
99,
|
|
190
|
+
"dummy.docx",
|
|
191
|
+
),
|
|
192
|
+
).toThrow(/Document page 99 is out of range/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("invalid page values throw with actionable message", () => {
|
|
196
|
+
const body = makeSmallBody(3);
|
|
197
|
+
expect(() =>
|
|
198
|
+
build_search_response(
|
|
199
|
+
body,
|
|
200
|
+
"TARGET_PHRASE",
|
|
201
|
+
false,
|
|
202
|
+
true,
|
|
203
|
+
0,
|
|
204
|
+
"dummy.docx",
|
|
205
|
+
),
|
|
206
|
+
).toThrow(/Invalid page value/);
|
|
207
|
+
expect(() =>
|
|
208
|
+
build_search_response(
|
|
209
|
+
body,
|
|
210
|
+
"TARGET_PHRASE",
|
|
211
|
+
false,
|
|
212
|
+
true,
|
|
213
|
+
"garbage",
|
|
214
|
+
"dummy.docx",
|
|
215
|
+
),
|
|
216
|
+
).toThrow(/Invalid page value/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("no matches anywhere: standard empty message", () => {
|
|
220
|
+
const body = makeSmallBody(3);
|
|
221
|
+
const res = build_search_response(
|
|
222
|
+
body,
|
|
223
|
+
"NONEXISTENT_TOKEN",
|
|
224
|
+
false,
|
|
225
|
+
true,
|
|
226
|
+
undefined,
|
|
227
|
+
"dummy.docx",
|
|
228
|
+
);
|
|
229
|
+
expect(res.content[0].text).toContain("No matches found");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("occurrence counts are global, not filtered", () => {
|
|
233
|
+
const body = makeMultiPageBody();
|
|
234
|
+
// Filter to one specific page and verify the occurrence count reflects the full document.
|
|
235
|
+
const allRes = build_search_response(
|
|
236
|
+
body,
|
|
237
|
+
"TARGET_PHRASE",
|
|
238
|
+
false,
|
|
239
|
+
true,
|
|
240
|
+
"all",
|
|
241
|
+
"dummy.docx",
|
|
242
|
+
);
|
|
243
|
+
const totalMatches = (allRes.content[0].text.match(/### Match /g) || [])
|
|
244
|
+
.length;
|
|
245
|
+
expect(totalMatches).toBe(5);
|
|
246
|
+
|
|
247
|
+
// Now filter to the first hit page.
|
|
248
|
+
const firstPageMatch = allRes.content[0].text.match(
|
|
249
|
+
/### Match 1 \(p(\d+)\)/,
|
|
250
|
+
);
|
|
251
|
+
const firstHitPage = parseInt(firstPageMatch![1], 10);
|
|
252
|
+
|
|
253
|
+
const filteredRes = build_search_response(
|
|
254
|
+
body,
|
|
255
|
+
"TARGET_PHRASE",
|
|
256
|
+
false,
|
|
257
|
+
true,
|
|
258
|
+
firstHitPage,
|
|
259
|
+
"dummy.docx",
|
|
260
|
+
);
|
|
261
|
+
// The "Occurrences" line should still report the global count of 5.
|
|
262
|
+
expect(filteredRes.content[0].text).toContain(
|
|
263
|
+
"This exact phrasing appears 5 times in the document",
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
});
|