@tikoci/rosetta 0.5.0 → 0.5.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.
@@ -0,0 +1,67 @@
1
+ // Set BEFORE any import that transitively loads db.ts
2
+ process.env.DB_PATH = ":memory:";
3
+
4
+ import { describe, expect, it } from "bun:test";
5
+ import { parseHTML } from "linkedom";
6
+
7
+ // Dynamic import so the DB_PATH assignment above wins over module caching
8
+ const { extractPlainText, sanitizeExtractedText } = await import("./extract-html.ts");
9
+
10
+ describe("sanitizeExtractedText", () => {
11
+ it("removes Confluence TOC CDATA style blocks", () => {
12
+ const input = `
13
+ Intro
14
+ /*<![CDATA[*/
15
+ div.rbtoc1774430868497 {padding: 0px;}
16
+ div.rbtoc1774430868497 ul {margin-left: 0px;}
17
+ div.rbtoc1774430868497 li {margin-left: 0px;padding-left: 0px;}
18
+ /*]]>*/
19
+ Basic Setup
20
+ `;
21
+
22
+ const out = sanitizeExtractedText(input);
23
+ expect(out).toContain("Intro");
24
+ expect(out).toContain("Basic Setup");
25
+ expect(out).not.toContain("rbtoc1774430868497");
26
+ expect(out).not.toContain("/*<![CDATA[*/");
27
+ expect(out).not.toContain("/*]]>*/");
28
+ });
29
+
30
+ it("removes bare rbtoc css lines without wrappers", () => {
31
+ const input = `
32
+ Top
33
+
34
+ div.rbtoc1234 {padding: 0px;}
35
+ div.rbtoc1234 ul {margin-left: 0px;}
36
+
37
+ after
38
+ `;
39
+
40
+ const out = sanitizeExtractedText(input);
41
+ expect(out).toContain("Top");
42
+ expect(out).toContain("after");
43
+ expect(out).not.toContain("div.rbtoc1234");
44
+ });
45
+
46
+ it("keeps normal RouterOS content", () => {
47
+ const input = "/interface vrrp add interface=ether1 vrid=49 priority=254";
48
+ const out = sanitizeExtractedText(input);
49
+ expect(out).toBe(input);
50
+ });
51
+ });
52
+
53
+ describe("extractPlainText", () => {
54
+ it("keeps heading and paragraph separated", () => {
55
+ const { document } = parseHTML("<div><h1>Basic Setup</h1><p>This is the basic VRRP configuration example.</p></div>");
56
+ const out = extractPlainText(document.querySelector("div"));
57
+ expect(out).toContain("Basic Setup\nThis is the basic VRRP configuration example.");
58
+ expect(out).not.toContain("Basic SetupThis");
59
+ });
60
+
61
+ it("keeps subsection heading boundaries", () => {
62
+ const { document } = parseHTML("<div><h2>Overview</h2><p>The Point-to-Point Protocol (PPP) provides...</p></div>");
63
+ const out = extractPlainText(document.querySelector("div"));
64
+ expect(out).toContain("Overview\nThe Point-to-Point Protocol (PPP) provides...");
65
+ expect(out).not.toContain("OverviewThe");
66
+ });
67
+ });
@@ -64,6 +64,120 @@ interface SectionRow {
64
64
  sort_order: number;
65
65
  }
66
66
 
67
+ const BLOCK_TAGS = new Set([
68
+ "address",
69
+ "article",
70
+ "aside",
71
+ "blockquote",
72
+ "dd",
73
+ "div",
74
+ "dl",
75
+ "dt",
76
+ "fieldset",
77
+ "figcaption",
78
+ "figure",
79
+ "footer",
80
+ "form",
81
+ "h1",
82
+ "h2",
83
+ "h3",
84
+ "h4",
85
+ "h5",
86
+ "h6",
87
+ "header",
88
+ "hr",
89
+ "li",
90
+ "main",
91
+ "nav",
92
+ "ol",
93
+ "p",
94
+ "pre",
95
+ "section",
96
+ "table",
97
+ "tbody",
98
+ "td",
99
+ "th",
100
+ "thead",
101
+ "tr",
102
+ "ul",
103
+ ]);
104
+
105
+ /**
106
+ * Remove Confluence TOC style leakage that may survive DOM cleanup when HTML is malformed.
107
+ * Keeps cleanup narrow to rbtoc CSS so normal content is not affected.
108
+ */
109
+ export function sanitizeExtractedText(input: string): string {
110
+ if (!input) return "";
111
+
112
+ return input
113
+ // Full CDATA-wrapped TOC style block.
114
+ .replace(/\/\*<!\[CDATA\[\*\/[\s\S]*?\/\*\]\]>\*\//g, "")
115
+ // Bare TOC CSS lines that can leak without wrappers.
116
+ .replace(/^\s*div\.rbtoc\d+[^\n]*(?:\n\s*div\.rbtoc\d+[^\n]*)*/gm, "")
117
+ // Any orphaned CDATA markers.
118
+ .replace(/\/\*<!\[CDATA\[\*\//g, "")
119
+ .replace(/\/\*\]\]>\*\//g, "")
120
+ // Prevent giant gaps in rendered output.
121
+ .replace(/\n{3,}/g, "\n\n")
122
+ .trim();
123
+ }
124
+
125
+ /**
126
+ * Convert a DOM subtree to readable plain text while preserving block boundaries.
127
+ * This avoids merged tokens like "OverviewThe" caused by raw textContent collapse.
128
+ */
129
+ export function extractPlainText(root: Element | null): string {
130
+ if (!root) return "";
131
+
132
+ let out = "";
133
+
134
+ const addBreak = () => {
135
+ if (!out) return;
136
+ if (!out.endsWith("\n")) out += "\n";
137
+ };
138
+
139
+ const addText = (text: string) => {
140
+ const normalized = text.replace(/\s+/g, " ").trim();
141
+ if (!normalized) return;
142
+ if (out && !out.endsWith("\n") && !out.endsWith(" ")) out += " ";
143
+ out += normalized;
144
+ };
145
+
146
+ const walk = (node: Node) => {
147
+ if (node.nodeType === 3) {
148
+ addText(node.textContent || "");
149
+ return;
150
+ }
151
+
152
+ if (node.nodeType !== 1) return;
153
+
154
+ const el = node as Element;
155
+ const tag = el.tagName.toLowerCase();
156
+
157
+ if (tag === "style" || tag === "script" || tag === "noscript") return;
158
+ if (tag === "br") {
159
+ addBreak();
160
+ return;
161
+ }
162
+
163
+ const isBlock = BLOCK_TAGS.has(tag);
164
+ if (isBlock) addBreak();
165
+
166
+ for (const child of el.childNodes) {
167
+ walk(child as unknown as Node);
168
+ }
169
+
170
+ if (isBlock) addBreak();
171
+ };
172
+
173
+ walk(root as unknown as Node);
174
+
175
+ return out
176
+ .replace(/[ \t]*\n[ \t]*/g, "\n")
177
+ .replace(/\n{3,}/g, "\n\n")
178
+ .trim();
179
+ }
180
+
67
181
  /**
68
182
  * Split main content into sections by h1–h3 headings with id attributes.
69
183
  * Uses innerHTML + regex to locate heading boundaries, then parses each
@@ -105,7 +219,7 @@ function extractSections(mainContent: Element, pageId: number): SectionRow[] {
105
219
  codeChunks.push(ce.textContent?.trim() || "");
106
220
  }
107
221
 
108
- const text = root?.textContent?.trim() || "";
222
+ const text = sanitizeExtractedText(extractPlainText(root));
109
223
  const code = codeChunks.join("\n\n");
110
224
 
111
225
  return {
@@ -179,8 +293,15 @@ function extractPage(file: string, html: string): (PageRow & { callouts: Callout
179
293
  const codeLang = codeLangs.size > 0 ? [...codeLangs].join(",") : null;
180
294
  const codeLines = code.split("\n").filter((l) => l.trim()).length;
181
295
 
296
+ // Remove <style> elements before extracting text — they contain Confluence
297
+ // table-of-contents CSS (CDATA blocks like div.rbtoc... {padding:0}) that
298
+ // otherwise pollute the plain-text index and appear in page views.
299
+ for (const styleEl of mainContent?.querySelectorAll("style") ?? []) {
300
+ styleEl.remove();
301
+ }
302
+
182
303
  // Plain text from main content (includes code block text too, which is fine for FTS)
183
- const text = mainContent?.textContent?.trim() || "";
304
+ const text = sanitizeExtractedText(extractPlainText(mainContent));
184
305
  const wordCount = text.split(/\s+/).filter(Boolean).length;
185
306
 
186
307
  // Callouts: extract note/warning/info blocks
@@ -231,149 +352,155 @@ function extractPage(file: string, html: string): (PageRow & { callouts: Callout
231
352
 
232
353
  // ---- Main ----
233
354
 
234
- console.log("Initializing database...");
235
- initDb();
355
+ function main() {
356
+ console.log("Initializing database...");
357
+ initDb();
236
358
 
237
359
  // Drop existing data for clean re-extraction (respect FK order)
238
- db.run("DELETE FROM sections;");
239
- db.run("DELETE FROM callouts;");
240
- db.run("INSERT INTO callouts_fts(callouts_fts) VALUES('rebuild');");
241
- db.run("DELETE FROM properties;");
242
- db.run("INSERT INTO properties_fts(properties_fts) VALUES('rebuild');");
243
- db.run("PRAGMA foreign_keys = OFF;");
244
- db.run("DELETE FROM pages;");
245
- db.run("PRAGMA foreign_keys = ON;");
246
- db.run("INSERT INTO pages_fts(pages_fts) VALUES('rebuild');");
247
-
248
- const htmlFiles = readdirSync(HTML_DIR)
249
- .filter((f) => f.endsWith(".html") && f !== "index.html")
250
- .sort();
251
-
252
- console.log(`Extracting ${htmlFiles.length} HTML files from ${HTML_DIR}`);
360
+ db.run("DELETE FROM sections;");
361
+ db.run("DELETE FROM callouts;");
362
+ db.run("INSERT INTO callouts_fts(callouts_fts) VALUES('rebuild');");
363
+ db.run("DELETE FROM properties;");
364
+ db.run("INSERT INTO properties_fts(properties_fts) VALUES('rebuild');");
365
+ db.run("PRAGMA foreign_keys = OFF;");
366
+ db.run("DELETE FROM pages;");
367
+ db.run("PRAGMA foreign_keys = ON;");
368
+ db.run("INSERT INTO pages_fts(pages_fts) VALUES('rebuild');");
369
+
370
+ const htmlFiles = readdirSync(HTML_DIR)
371
+ .filter((f) => f.endsWith(".html") && f !== "index.html")
372
+ .sort();
373
+
374
+ console.log(`Extracting ${htmlFiles.length} HTML files from ${HTML_DIR}`);
253
375
 
254
376
  // Two-pass insert: first without parent_id (avoids FK ordering issues),
255
377
  // then update parent relationships.
256
- const insertPage = db.prepare(`
378
+ const insertPage = db.prepare(`
257
379
  INSERT OR REPLACE INTO pages
258
380
  (id, slug, title, path, depth, parent_id, url, text, code, code_lang,
259
381
  author, last_updated, word_count, code_lines, html_file)
260
382
  VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
261
383
  `);
262
- const updateParent = db.prepare("UPDATE pages SET parent_id = ? WHERE id = ?");
384
+ const updateParent = db.prepare("UPDATE pages SET parent_id = ? WHERE id = ?");
263
385
 
264
- let extracted = 0;
265
- let skipped = 0;
266
- let totalWords = 0;
267
- let totalCodeLines = 0;
268
- let totalCallouts = 0;
386
+ let extracted = 0;
387
+ let skipped = 0;
388
+ let totalWords = 0;
389
+ let totalCodeLines = 0;
390
+ let totalCallouts = 0;
269
391
 
270
- const allPages: (PageRow & { callouts: CalloutRow[]; sections: SectionRow[] })[] = [];
392
+ const allPages: (PageRow & { callouts: CalloutRow[]; sections: SectionRow[] })[] = [];
271
393
 
272
394
  // Pass 1: extract and insert all pages (parent_id = NULL)
273
- const insertAll = db.transaction(() => {
274
- for (const file of htmlFiles) {
275
- const html = readFileSync(resolve(HTML_DIR, file), "utf-8");
276
- const page = extractPage(file, html);
277
- if (!page) {
278
- skipped++;
279
- console.warn(` skipped: ${file}`);
280
- continue;
395
+ const insertAll = db.transaction(() => {
396
+ for (const file of htmlFiles) {
397
+ const html = readFileSync(resolve(HTML_DIR, file), "utf-8");
398
+ const page = extractPage(file, html);
399
+ if (!page) {
400
+ skipped++;
401
+ console.warn(` skipped: ${file}`);
402
+ continue;
403
+ }
404
+ insertPage.run(
405
+ page.id,
406
+ page.slug,
407
+ page.title,
408
+ page.path,
409
+ page.depth,
410
+ page.url,
411
+ page.text,
412
+ page.code,
413
+ page.code_lang,
414
+ page.author,
415
+ page.last_updated,
416
+ page.word_count,
417
+ page.code_lines,
418
+ page.html_file,
419
+ );
420
+ allPages.push(page);
421
+ extracted++;
422
+ totalWords += page.word_count;
423
+ totalCodeLines += page.code_lines;
281
424
  }
282
- insertPage.run(
283
- page.id,
284
- page.slug,
285
- page.title,
286
- page.path,
287
- page.depth,
288
- page.url,
289
- page.text,
290
- page.code,
291
- page.code_lang,
292
- page.author,
293
- page.last_updated,
294
- page.word_count,
295
- page.code_lines,
296
- page.html_file,
297
- );
298
- allPages.push(page);
299
- extracted++;
300
- totalWords += page.word_count;
301
- totalCodeLines += page.code_lines;
302
- }
303
- });
304
- insertAll();
425
+ });
426
+ insertAll();
305
427
 
306
428
  // Pass 2: set parent_id where the parent actually exists in the DB
307
- const pageIds = new Set(allPages.map((p) => p.id));
308
- const setParents = db.transaction(() => {
309
- for (const page of allPages) {
310
- if (page.parent_id && pageIds.has(page.parent_id)) {
311
- updateParent.run(page.parent_id, page.id);
429
+ const pageIds = new Set(allPages.map((p) => p.id));
430
+ const setParents = db.transaction(() => {
431
+ for (const page of allPages) {
432
+ if (page.parent_id && pageIds.has(page.parent_id)) {
433
+ updateParent.run(page.parent_id, page.id);
434
+ }
312
435
  }
313
- }
314
- });
315
- setParents();
436
+ });
437
+ setParents();
316
438
 
317
439
  // Pass 3: insert callouts
318
- const insertCallout = db.prepare(`
440
+ const insertCallout = db.prepare(`
319
441
  INSERT INTO callouts (page_id, type, content, sort_order)
320
442
  VALUES (?, ?, ?, ?)
321
443
  `);
322
- const insertCallouts = db.transaction(() => {
323
- for (const page of allPages) {
324
- for (const c of page.callouts) {
325
- insertCallout.run(c.page_id, c.type, c.content, c.sort_order);
326
- totalCallouts++;
444
+ const insertCallouts = db.transaction(() => {
445
+ for (const page of allPages) {
446
+ for (const c of page.callouts) {
447
+ insertCallout.run(c.page_id, c.type, c.content, c.sort_order);
448
+ totalCallouts++;
449
+ }
327
450
  }
328
- }
329
- });
330
- insertCallouts();
451
+ });
452
+ insertCallouts();
331
453
 
332
454
  // Pass 4: insert sections
333
- let totalSections = 0;
334
- let pagesWithSections = 0;
335
- const insertSection = db.prepare(`
455
+ let totalSections = 0;
456
+ let pagesWithSections = 0;
457
+ const insertSection = db.prepare(`
336
458
  INSERT INTO sections (page_id, heading, level, anchor_id, text, code, word_count, sort_order)
337
459
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
338
460
  `);
339
- const insertSections = db.transaction(() => {
340
- for (const page of allPages) {
341
- if (page.sections.length > 0) {
342
- pagesWithSections++;
343
- for (const s of page.sections) {
344
- insertSection.run(s.page_id, s.heading, s.level, s.anchor_id, s.text, s.code, s.word_count, s.sort_order);
345
- totalSections++;
461
+ const insertSections = db.transaction(() => {
462
+ for (const page of allPages) {
463
+ if (page.sections.length > 0) {
464
+ pagesWithSections++;
465
+ for (const s of page.sections) {
466
+ insertSection.run(s.page_id, s.heading, s.level, s.anchor_id, s.text, s.code, s.word_count, s.sort_order);
467
+ totalSections++;
468
+ }
346
469
  }
347
470
  }
348
- }
349
- });
350
- insertSections();
471
+ });
472
+ insertSections();
351
473
 
352
- const ftsCount = (db.prepare("SELECT COUNT(*) as c FROM pages_fts").get() as { c: number }).c;
474
+ const ftsCount = (db.prepare("SELECT COUNT(*) as c FROM pages_fts").get() as { c: number }).c;
353
475
 
354
- console.log(`\nExtraction complete:`);
355
- console.log(` Pages extracted: ${extracted}`);
356
- console.log(` Pages skipped: ${skipped}`);
357
- console.log(` Total words: ${totalWords.toLocaleString()}`);
358
- console.log(` Total code lines: ${totalCodeLines.toLocaleString()}`);
359
- console.log(` Total callouts: ${totalCallouts}`);
360
- console.log(` Total sections: ${totalSections} (across ${pagesWithSections} pages)`);
361
- console.log(` FTS index rows: ${ftsCount}`);
476
+ console.log(`\nExtraction complete:`);
477
+ console.log(` Pages extracted: ${extracted}`);
478
+ console.log(` Pages skipped: ${skipped}`);
479
+ console.log(` Total words: ${totalWords.toLocaleString()}`);
480
+ console.log(` Total code lines: ${totalCodeLines.toLocaleString()}`);
481
+ console.log(` Total callouts: ${totalCallouts}`);
482
+ console.log(` Total sections: ${totalSections} (across ${pagesWithSections} pages)`);
483
+ console.log(` FTS index rows: ${ftsCount}`);
362
484
 
363
485
  // Quick search test
364
- const testResults = db
365
- .prepare(
366
- `SELECT s.id, s.title, s.path,
367
- snippet(pages_fts, 2, '>>>', '<<<', '...', 20) as excerpt
368
- FROM pages_fts fts
369
- JOIN pages s ON s.id = fts.rowid
370
- WHERE pages_fts MATCH 'firewall filter'
371
- ORDER BY rank LIMIT 5`,
372
- )
373
- .all();
374
-
375
- console.log(`\nTest search for "firewall filter":`);
376
- for (const r of testResults as Array<{ id: number; title: string; path: string; excerpt: string }>) {
377
- console.log(` [${r.id}] ${r.path}`);
378
- console.log(` ${r.excerpt}`);
486
+ const testResults = db
487
+ .prepare(
488
+ `SELECT s.id, s.title, s.path,
489
+ snippet(pages_fts, 2, '>>>', '<<<', '...', 20) as excerpt
490
+ FROM pages_fts fts
491
+ JOIN pages s ON s.id = fts.rowid
492
+ WHERE pages_fts MATCH 'firewall filter'
493
+ ORDER BY rank LIMIT 5`,
494
+ )
495
+ .all();
496
+
497
+ console.log(`\nTest search for "firewall filter":`);
498
+ for (const r of testResults as Array<{ id: number; title: string; path: string; excerpt: string }>) {
499
+ console.log(` [${r.id}] ${r.path}`);
500
+ console.log(` ${r.excerpt}`);
501
+ }
502
+ }
503
+
504
+ if (import.meta.main) {
505
+ main();
379
506
  }
package/src/mcp.ts CHANGED
@@ -978,6 +978,10 @@ Workflow:
978
978
  → routeros_device_lookup: get full specs (CPU, RAM, pricing) + block diagram for a specific device
979
979
  → routeros_search: find documentation about features relevant to the test type`,
980
980
  inputSchema: {
981
+ device: z
982
+ .string()
983
+ .optional()
984
+ .describe("Filter by device product name (substring match, e.g., 'RB5009', 'hAP', 'CCR2216')"),
981
985
  test_type: z
982
986
  .string()
983
987
  .optional()
@@ -1010,8 +1014,8 @@ Workflow:
1010
1014
  .describe("Max results (default 50, max 200)"),
1011
1015
  },
1012
1016
  },
1013
- async ({ test_type, mode, configuration, packet_size, sort_by, limit }) => {
1014
- const hasFilters = test_type || mode || configuration || packet_size;
1017
+ async ({ device, test_type, mode, configuration, packet_size, sort_by, limit }) => {
1018
+ const hasFilters = device || test_type || mode || configuration || packet_size;
1015
1019
 
1016
1020
  if (!hasFilters) {
1017
1021
  // Discovery mode: return available filter values
@@ -1029,7 +1033,7 @@ Workflow:
1029
1033
  }
1030
1034
 
1031
1035
  const result = searchDeviceTests(
1032
- { test_type, mode, configuration, packet_size, sort_by },
1036
+ { device, test_type, mode, configuration, packet_size, sort_by },
1033
1037
  limit,
1034
1038
  );
1035
1039
 
@@ -1065,7 +1069,7 @@ server.registerTool(
1065
1069
  {
1066
1070
  description: `Fetch current RouterOS version numbers from MikroTik's upgrade server.
1067
1071
 
1068
- Returns the latest version for each release channel: stable, long-term, testing, development.
1072
+ Returns the latest version for each release channel (stable, long-term, testing, development) plus the current WinBox 4 version.
1069
1073
  Useful for determining if a user's version is current, outdated, or unpatched.
1070
1074
 
1071
1075
  Key context for version reasoning:
package/src/query.test.ts CHANGED
@@ -267,6 +267,10 @@ beforeAll(() => {
267
267
  db.run(`INSERT INTO device_test_results
268
268
  (device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
269
269
  VALUES (1, 'ipsec', 'Single tunnel', 'AES-128-CBC + SHA1', 1400, 120.9, 1354.1)`);
270
+ // RB5009UG+S+IN = id 9 (9th device inserted)
271
+ db.run(`INSERT INTO device_test_results
272
+ (device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
273
+ VALUES (9, 'ethernet', 'Routing', 'none (fast path)', 1518, 1613.0, 19577.3)`);
270
274
 
271
275
  // Page 3: a "large" page with sections for TOC testing
272
276
  // Text is ~200 chars to keep fixture small, but we'll use max_length=50 to trigger truncation
@@ -1154,14 +1158,14 @@ describe("searchDevices", () => {
1154
1158
  describe("searchDeviceTests", () => {
1155
1159
  test("returns all test results with no filters", () => {
1156
1160
  const res = searchDeviceTests({});
1157
- expect(res.results.length).toBe(3);
1158
- expect(res.total).toBe(3);
1161
+ expect(res.results.length).toBe(4);
1162
+ expect(res.total).toBe(4);
1159
1163
  });
1160
1164
 
1161
1165
  test("filters by test_type", () => {
1162
1166
  const res = searchDeviceTests({ test_type: "ethernet" });
1163
1167
  expect(res.results.every((r) => r.test_type === "ethernet")).toBe(true);
1164
- expect(res.results.length).toBe(2);
1168
+ expect(res.results.length).toBe(3);
1165
1169
  });
1166
1170
 
1167
1171
  test("filters by test_type and mode", () => {
@@ -1181,6 +1185,32 @@ describe("searchDeviceTests", () => {
1181
1185
  expect(res.results.every((r) => r.packet_size === 512)).toBe(true);
1182
1186
  });
1183
1187
 
1188
+ test("filters by device name (substring match)", () => {
1189
+ const res = searchDeviceTests({ device: "hAP" });
1190
+ expect(res.results).toHaveLength(3);
1191
+ expect(res.results.every((r) => r.product_name.includes("hAP"))).toBe(true);
1192
+ });
1193
+
1194
+ test("device filter combined with test_type", () => {
1195
+ const res = searchDeviceTests({ device: "hAP", test_type: "ethernet" });
1196
+ expect(res.results).toHaveLength(2);
1197
+ expect(res.results.every((r) => r.test_type === "ethernet")).toBe(true);
1198
+ expect(res.results.every((r) => r.product_name.includes("hAP"))).toBe(true);
1199
+ });
1200
+
1201
+ test("device filter with no matches returns empty", () => {
1202
+ const res = searchDeviceTests({ device: "Audience" });
1203
+ expect(res.results).toHaveLength(0);
1204
+ expect(res.total).toBe(0);
1205
+ });
1206
+
1207
+ test("device filter for RB5009 returns its test results", () => {
1208
+ const res = searchDeviceTests({ device: "RB5009" });
1209
+ expect(res.results).toHaveLength(1);
1210
+ expect(res.results[0].product_name).toBe("RB5009UG+S+IN");
1211
+ expect(res.results[0].packet_size).toBe(1518);
1212
+ });
1213
+
1184
1214
  test("sorts by mbps descending by default", () => {
1185
1215
  const res = searchDeviceTests({ test_type: "ethernet" });
1186
1216
  if (res.results.length >= 2) {
@@ -1210,7 +1240,7 @@ describe("searchDeviceTests", () => {
1210
1240
  test("respects limit", () => {
1211
1241
  const res = searchDeviceTests({}, 1);
1212
1242
  expect(res.results).toHaveLength(1);
1213
- expect(res.total).toBe(3);
1243
+ expect(res.total).toBe(4);
1214
1244
  });
1215
1245
  });
1216
1246
 
@@ -1220,9 +1250,10 @@ describe("dataset CSV exports", () => {
1220
1250
  const lines = csv.trim().split("\n");
1221
1251
 
1222
1252
  expect(lines[0]).toBe("product_name,product_code,architecture,cpu,cpu_cores,cpu_frequency,test_type,mode,configuration,packet_size,throughput_kpps,throughput_mbps,product_url");
1223
- expect(lines).toHaveLength(4);
1253
+ expect(lines).toHaveLength(5);
1224
1254
  expect(csv).toContain("hAP ax3");
1225
1255
  expect(csv).toContain("IPQ-6010");
1256
+ expect(csv).toContain("RB5009UG+S+IN");
1226
1257
  expect(csv).toContain("https://mikrotik.com/product/hap_ax3");
1227
1258
  });
1228
1259