@tikoci/rosetta 0.8.4 → 0.8.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "description": "RouterOS documentation as SQLite FTS5 — RAG search + command glossary via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/browse.ts CHANGED
@@ -200,7 +200,6 @@ function formatTime(seconds: number): string {
200
200
 
201
201
  // ── Pager ──
202
202
 
203
- /** Output lines with paging. Returns true if user quit early. */
204
203
  /**
205
204
  * Interactive pager.
206
205
  *
@@ -210,17 +209,26 @@ function formatTime(seconds: number): string {
210
209
  * b / < previous page
211
210
  * g jump to top
212
211
  * G jump to bottom
213
- * q quit pager (returns true)
212
+ * q quit pager
213
+ * 1-9 If `selectCount` is set and digit ≤ selectCount, **select**
214
+ * that result (returns { selected: N }). Otherwise, jump to
215
+ * page N within the pager.
214
216
  *
215
- * Shows a status line with page X/Y and line N/M. Returns true if user quit
216
- * before EOF, false otherwise.
217
+ * Shows a status line with page X/Y and line N/M. Returns:
218
+ * - { quit: true } user pressed q before EOF
219
+ * - { quit: false } reached EOF naturally
220
+ * - { quit: true, selected: N } user picked result N from the displayed list
217
221
  */
218
- async function paged(output: string): Promise<boolean> {
222
+ type PagerResult = { quit: boolean; selected?: number };
223
+
224
+ async function paged(output: string, opts?: { selectCount?: number }): Promise<PagerResult> {
225
+ const selectCount = opts?.selectCount ?? 0;
219
226
  const lines = output.split("\n");
220
227
  const pageSize = Math.max(5, termHeight() - 2);
221
228
  if (lines.length <= pageSize || !process.stdout.isTTY) {
222
229
  process.stdout.write(`${output}\n`);
223
- return false;
230
+ // Non-TTY (or single screen) — no interactive selection possible.
231
+ return { quit: false };
224
232
  }
225
233
  const totalPages = Math.ceil(lines.length / pageSize);
226
234
  let offset = 0;
@@ -228,15 +236,25 @@ async function paged(output: string): Promise<boolean> {
228
236
  const chunk = lines.slice(offset, offset + pageSize);
229
237
  process.stdout.write(`${chunk.join("\n")}\n`);
230
238
  const newOffset = offset + pageSize;
231
- if (newOffset >= lines.length) return false;
239
+ if (newOffset >= lines.length) return { quit: false };
232
240
  const curPage = Math.floor(newOffset / pageSize);
241
+ // Cap selectable digits at 9 (single keystroke).
242
+ const maxSel = Math.min(9, selectCount);
243
+ const selectHint = maxSel > 0
244
+ ? ` | 1-${maxSel} open #N`
245
+ : "";
246
+ const jumpHint = totalPages > 1 && maxSel < 9
247
+ ? ` | ${maxSel + 1}-${Math.min(9, totalPages)} jump page`
248
+ : totalPages > 1 && maxSel === 0
249
+ ? ` | 1-${Math.min(9, totalPages)} jump page`
250
+ : "";
233
251
  const prompt = dim(
234
- `── page ${curPage}/${totalPages} line ${newOffset}/${lines.length} [SPACE next | ENTER line | b back | 1-${Math.min(9, totalPages)} jump | g/G top/end | q quit]`,
252
+ `── page ${curPage}/${totalPages} line ${newOffset}/${lines.length} [SPACE next | ENTER line | b back${selectHint}${jumpHint} | g/G top/end | q quit]`,
235
253
  );
236
254
  process.stdout.write(prompt);
237
255
  const key = await waitForKey();
238
256
  process.stdout.write(`\r${" ".repeat(Math.min(termWidth(), stripAnsi(prompt).length))}\r`);
239
- if (key === "q" || key === "Q") return true;
257
+ if (key === "q" || key === "Q") return { quit: true };
240
258
  if (key === "g") { offset = 0; continue; }
241
259
  if (key === "G") { offset = Math.max(0, lines.length - pageSize); continue; }
242
260
  if (key === "b" || key === "<" || key === "\x1b[D") {
@@ -247,18 +265,26 @@ async function paged(output: string): Promise<boolean> {
247
265
  offset += 1;
248
266
  continue;
249
267
  }
250
- // Digit 1-9 jumps to that page (1-indexed). If the page is past EOF,
251
- // clamp to the last page.
252
268
  if (/^[1-9]$/.test(key)) {
253
- const target = Number.parseInt(key, 10);
254
- offset = Math.min(lines.length - pageSize, (target - 1) * pageSize);
269
+ const n = Number.parseInt(key, 10);
270
+ // Digits 1..selectCount SELECT the displayed result and end paging.
271
+ if (selectCount > 0 && n <= selectCount) {
272
+ return { quit: true, selected: n };
273
+ }
274
+ // Digits beyond selectCount → page jump (1-indexed). Clamp to last page.
275
+ offset = Math.min(lines.length - pageSize, (n - 1) * pageSize);
255
276
  if (offset < 0) offset = 0;
256
277
  continue;
257
278
  }
258
279
  // SPACE / f / anything else → next page
259
280
  offset = newOffset;
260
281
  }
261
- return false;
282
+ return { quit: false };
283
+ }
284
+
285
+ async function selectFromPager(output: string, selectCount: number): Promise<void> {
286
+ const result = await paged(output, { selectCount });
287
+ if (result.selected !== undefined) await handleNumberSelect(result.selected - 1);
262
288
  }
263
289
 
264
290
  function waitForKey(): Promise<string> {
@@ -1053,7 +1079,8 @@ function renderHelp(): string {
1053
1079
 
1054
1080
  cmd("<query>", "", "Search pages (default action)", "routeros_search");
1055
1081
  cmd("search <query>", "s", "Explicit page search", "routeros_search");
1056
- cmd("page <id|title>", "", "View full page", "routeros_get_page");
1082
+ cmd("page <id|title>", "", "View full page (no args = re-render current)", "routeros_get_page");
1083
+ cmd("view", "v", "Re-render current view (without popping the stack like `b`)");
1057
1084
  cmd("prop <name>", "p", "Look up property (scoped to current page)", "routeros_lookup_property");
1058
1085
  cmd("props <query>", "sp", "Search properties by FTS");
1059
1086
  cmd("glossary [term]", "g", "Look up RouterOS jargon / list glossary");
@@ -1081,8 +1108,8 @@ function renderHelp(): string {
1081
1108
  out.push(` ${cyan(pad(".resources", 38))} ${dim("Registered MCP resources (rosetta:// URIs)")}`);
1082
1109
  out.push(` ${cyan(pad(".routeros_search <q> [limit=N]", 38))} ${dim("Raw JSON output, same query path as MCP")}`);
1083
1110
  out.push("");
1084
- out.push(` ${dim("Navigation: type a number to select from results; in pager, 1–9 jumps to page N.")}`);
1085
- out.push(` ${dim("After viewing a page, [p] = properties, [cal] = page callouts, [b] = re-render previous view.")}`);
1111
+ out.push(` ${dim("Navigation: type a number to select from results; in pager, 1–N opens result #N (1-indexed).")}`);
1112
+ out.push(` ${dim("After viewing a page, [v] = re-render, [N] = go to section N, [p] = properties, [cal] = callouts, [b] = back.")}`);
1086
1113
  out.push(` ${dim("URLs are clickable in supported terminals (iTerm2, etc.).")}`);
1087
1114
  out.push(` ${dim("cmd supports @version suffix: cmd /ip/address @7.15")}`);
1088
1115
  out.push("");
@@ -1358,6 +1385,27 @@ async function dispatchDotCommand(input: string): Promise<void> {
1358
1385
  }
1359
1386
  try {
1360
1387
  const args = parseDotArgs(m[2] ?? "", tool.primary, tool.aliases);
1388
+ // If the tool has a primary arg and the user supplied no value for it
1389
+ // (and no key=value args at all), show a usage hint instead of running
1390
+ // the tool against an empty/null query — calling with no args usually
1391
+ // returns null or an empty object, which is confusing for users probing
1392
+ // the MCP surface for the first time.
1393
+ if (
1394
+ tool.primary &&
1395
+ (args[tool.primary] === undefined || args[tool.primary] === "") &&
1396
+ Object.keys(args).length === 0
1397
+ ) {
1398
+ const aliasStr = tool.aliases
1399
+ ? Object.keys(tool.aliases).map((a) => `${a}=`).join(" | ")
1400
+ : "";
1401
+ const primaryHint = aliasStr
1402
+ ? `${tool.primary}= (or ${aliasStr})`
1403
+ : `${tool.primary}=`;
1404
+ console.log(` ${dim("Usage:")} ${cyan(`.${name}`)} ${dim(`<${tool.primary}> | ${primaryHint}<value> [key=value ...]`)}`);
1405
+ console.log(` ${dim(tool.desc)}`);
1406
+ if (tool.tui) console.log(` ${dim("TUI equivalent:")} ${cyan(tool.tui)}`);
1407
+ return;
1408
+ }
1361
1409
  const result = await tool.run(args);
1362
1410
  const json = JSON.stringify(result, null, 2);
1363
1411
  const banner = dim(`── .${name} args=${JSON.stringify(args)}`);
@@ -1426,16 +1474,38 @@ async function dispatch(input: string): Promise<void> {
1426
1474
  return;
1427
1475
 
1428
1476
  case "page": {
1429
- if (!rest) { console.log(dim(" Usage: page <id|title>")); return; }
1477
+ if (!rest) {
1478
+ // No args: re-render current page if we're already viewing one.
1479
+ if (ctx.type === "page" || ctx.type === "sections") {
1480
+ await renderCurrentContext();
1481
+ return;
1482
+ }
1483
+ console.log(dim(" Usage: page <id|title>"));
1484
+ return;
1485
+ }
1430
1486
  await doPage(rest);
1431
1487
  return;
1432
1488
  }
1433
1489
 
1490
+ case "v":
1491
+ case "view": {
1492
+ // Re-render the current view — useful after exiting the pager (q) and
1493
+ // wanting to see the results/page again without losing breadcrumb state
1494
+ // (which `b` would do by popping one level).
1495
+ if (ctx.type === "home") {
1496
+ console.log(dim(" Nothing to view yet. Try: help"));
1497
+ return;
1498
+ }
1499
+ await renderCurrentContext();
1500
+ return;
1501
+ }
1502
+
1434
1503
  case "p":
1435
1504
  case "prop": {
1436
1505
  if (!rest) {
1437
1506
  // Context-scoped: show properties for current page
1438
- if (ctx.type === "page") {
1507
+ // "sections" context is also a page view (pages with headings push sections, not page)
1508
+ if (ctx.type === "page" || ctx.type === "sections") {
1439
1509
  const page = getPage(ctx.pageId, 0); // just get metadata
1440
1510
  if (page) {
1441
1511
  await doPropsForPage(ctx.pageId, ctx.title);
@@ -1483,13 +1553,14 @@ async function dispatch(input: string): Promise<void> {
1483
1553
 
1484
1554
  case "cal":
1485
1555
  case "callouts": {
1486
- if (!rest && ctx.type === "page") {
1556
+ // "sections" context is also a page view (pages with headings push sections, not page)
1557
+ if (!rest && (ctx.type === "page" || ctx.type === "sections")) {
1487
1558
  // Page-scoped: query callouts directly by page_id (the
1488
1559
  // FTS-with-empty-query path always returned [] before).
1489
- const pageCallouts = getPageCallouts((ctx as { pageId: number }).pageId);
1560
+ const pageCallouts = getPageCallouts(ctx.pageId);
1490
1561
  if (pageCallouts.length > 0) {
1491
- await paged(renderCallouts(pageCallouts));
1492
1562
  pushCtx({ type: "callouts", query: "", results: pageCallouts });
1563
+ await selectFromPager(renderCallouts(pageCallouts), pageCallouts.length);
1493
1564
  } else {
1494
1565
  console.log(dim(" This page has no callouts."));
1495
1566
  }
@@ -1666,36 +1737,36 @@ async function renderCurrentContext(): Promise<void> {
1666
1737
  console.log(dim(" ← back to home. Type 'help' for commands."));
1667
1738
  return;
1668
1739
  case "search":
1669
- await paged(renderSearchResults(ctx.response));
1740
+ await selectFromPager(renderSearchResults(ctx.response), ctx.results.length);
1670
1741
  return;
1671
1742
  case "page":
1672
1743
  case "sections": {
1673
1744
  const page = getPage(ctx.pageId);
1674
- if (page) await paged(renderPage(page));
1745
+ if (page) await selectFromPager(renderPage(page), page.sections?.length ?? 0);
1675
1746
  return;
1676
1747
  }
1677
1748
  case "commands": {
1678
1749
  const children = browseCommands(ctx.path);
1679
- await paged(renderCommandTree(ctx.path, children));
1750
+ await selectFromPager(renderCommandTree(ctx.path, children), children.length);
1680
1751
  return;
1681
1752
  }
1682
1753
  case "devices":
1683
- await paged(renderDeviceResults(ctx.results, "search", ctx.results.length));
1754
+ await selectFromPager(renderDeviceResults(ctx.results, "search", ctx.results.length), ctx.results.length);
1684
1755
  return;
1685
1756
  case "device":
1686
1757
  await paged(renderDeviceCard(ctx.device));
1687
1758
  return;
1688
1759
  case "callouts":
1689
- await paged(renderCallouts(ctx.results));
1760
+ await selectFromPager(renderCallouts(ctx.results), ctx.results.length);
1690
1761
  return;
1691
1762
  case "changelogs":
1692
- await paged(renderChangelogs(ctx.results));
1763
+ await selectFromPager(renderChangelogs(ctx.results), ctx.results.length);
1693
1764
  return;
1694
1765
  case "videos":
1695
- await paged(renderVideos(ctx.results));
1766
+ await selectFromPager(renderVideos(ctx.results), ctx.results.length);
1696
1767
  return;
1697
1768
  case "dude":
1698
- await paged(renderDudeResults(ctx.results));
1769
+ await selectFromPager(renderDudeResults(ctx.results), ctx.results.length);
1699
1770
  return;
1700
1771
  case "skills":
1701
1772
  await doListSkills();
@@ -1706,7 +1777,7 @@ async function renderCurrentContext(): Promise<void> {
1706
1777
  const p = ctx.results[i];
1707
1778
  lines.push(` ${cyan(String(i + 1).padStart(2))}. ${bold(p.name)} ${dim(`@ ${p.page_title}`)}`);
1708
1779
  }
1709
- await paged(lines.join("\n"));
1780
+ await selectFromPager(lines.join("\n"), ctx.results.length);
1710
1781
  return;
1711
1782
  }
1712
1783
  case "tests":
@@ -1719,8 +1790,10 @@ async function renderCurrentContext(): Promise<void> {
1719
1790
 
1720
1791
  async function doSearch(query: string): Promise<void> {
1721
1792
  const resp = searchAll(query);
1722
- await paged(renderSearchResults(resp));
1793
+ // Push context BEFORE paging so digit-selection in the pager (and the
1794
+ // post-pager handleNumberSelect call) both see the right ctx.
1723
1795
  pushCtx({ type: "search", response: resp, results: resp.pages });
1796
+ await selectFromPager(renderSearchResults(resp), resp.pages.length);
1724
1797
  }
1725
1798
 
1726
1799
  async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
@@ -1733,7 +1806,6 @@ async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
1733
1806
  console.log(dim(` Page not found: ${idOrTitle}`));
1734
1807
  return;
1735
1808
  }
1736
- await paged(renderPage(page));
1737
1809
 
1738
1810
  // Determine linked command path (if any)
1739
1811
  let commandPath: string | undefined;
@@ -1749,6 +1821,7 @@ async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
1749
1821
  } else {
1750
1822
  pushCtx({ type: "page", pageId: page.id, title: page.title, commandPath });
1751
1823
  }
1824
+ await selectFromPager(renderPage(page), page.sections?.length ?? 0);
1752
1825
  }
1753
1826
 
1754
1827
  async function doPropsForPage(pageId: number, title: string): Promise<void> {
@@ -1759,8 +1832,8 @@ async function doPropsForPage(pageId: number, title: string): Promise<void> {
1759
1832
  console.log(dim(` No properties found for "${title}".`));
1760
1833
  return;
1761
1834
  }
1762
- await paged(` ${bold("Properties for")} ${bold(title)}\n\n${renderProperties(pageProps)}`);
1763
1835
  pushCtx({ type: "properties", query: title, pageId, results: pageProps.map(p => ({ name: p.name, page_id: p.page_id ?? pageId, page_title: p.page_title })) });
1836
+ await selectFromPager(` ${bold("Properties for")} ${bold(title)}\n\n${renderProperties(pageProps)}`, pageProps.length);
1764
1837
  }
1765
1838
 
1766
1839
  async function doLookupProperty(name: string): Promise<void> {
@@ -1771,8 +1844,8 @@ async function doLookupProperty(name: string): Promise<void> {
1771
1844
  console.log(` Try: ${cyan("props")} ${name}`);
1772
1845
  return;
1773
1846
  }
1774
- await paged(renderProperties(results));
1775
1847
  pushCtx({ type: "properties", query: name, results: results.map(p => ({ name: p.name, page_id: p.page_id, page_title: p.page_title })) });
1848
+ await selectFromPager(renderProperties(results), results.length);
1776
1849
  }
1777
1850
 
1778
1851
  async function doSearchProperties(query: string): Promise<void> {
@@ -1781,8 +1854,8 @@ async function doSearchProperties(query: string): Promise<void> {
1781
1854
  console.log(dim(` No properties found for "${query}".`));
1782
1855
  return;
1783
1856
  }
1784
- await paged(` ${bold(String(results.length))} properties matching ${cyan(`"${query}"`)}\n\n${renderProperties(results)}`);
1785
1857
  pushCtx({ type: "properties", query, results: results.map(p => ({ name: p.name, page_id: p.page_id, page_title: p.page_title })) });
1858
+ await selectFromPager(` ${bold(String(results.length))} properties matching ${cyan(`"${query}"`)}\n\n${renderProperties(results)}`, results.length);
1786
1859
  }
1787
1860
 
1788
1861
  async function doGlossary(rest: string): Promise<void> {
@@ -1875,8 +1948,8 @@ async function doCommandTree(path: string): Promise<void> {
1875
1948
  return;
1876
1949
  }
1877
1950
  const label = version ? `${cmdPath} @${version}` : cmdPath;
1878
- await paged(renderCommandTree(label, children));
1879
1951
  pushCtx({ type: "commands", path: cmdPath });
1952
+ await selectFromPager(renderCommandTree(label, children), children.length);
1880
1953
  }
1881
1954
 
1882
1955
  async function doDeviceLookup(query: string): Promise<void> {
@@ -1885,12 +1958,15 @@ async function doDeviceLookup(query: string): Promise<void> {
1885
1958
  console.log(dim(` No devices found for "${query}".`));
1886
1959
  return;
1887
1960
  }
1888
- await paged(renderDeviceResults(result.results, result.mode, result.total));
1889
1961
  if (result.results.length === 1) {
1890
1962
  pushCtx({ type: "device", device: result.results[0] });
1891
1963
  } else {
1892
1964
  pushCtx({ type: "devices", query, results: result.results });
1893
1965
  }
1966
+ await selectFromPager(
1967
+ renderDeviceResults(result.results, result.mode, result.total),
1968
+ result.results.length > 1 ? result.results.length : 0,
1969
+ );
1894
1970
  }
1895
1971
 
1896
1972
  async function doTests(argsStr: string): Promise<void> {
@@ -1948,8 +2024,11 @@ async function doSearchCallouts(query: string): Promise<void> {
1948
2024
  console.log(dim(` No callouts found.`));
1949
2025
  return;
1950
2026
  }
1951
- await paged(` ${bold(String(results.length))} callouts${type ? ` (${type})` : ""}\n\n${renderCallouts(results)}`);
1952
2027
  pushCtx({ type: "callouts", query, results });
2028
+ await selectFromPager(
2029
+ ` ${bold(String(results.length))} callouts${type ? ` (${type})` : ""}\n\n${renderCallouts(results)}`,
2030
+ results.length,
2031
+ );
1953
2032
  }
1954
2033
 
1955
2034
  async function doSearchChangelogs(query: string): Promise<void> {
@@ -1997,8 +2076,8 @@ async function doSearchChangelogs(query: string): Promise<void> {
1997
2076
  console.log(dim(" No changelog entries found."));
1998
2077
  return;
1999
2078
  }
2000
- await paged(` ${bold("Changelogs")}${version ? ` for ${bold(version)}` : ""}${breakingOnly ? ` ${red("(breaking only)")}` : ""}\n\n${renderChangelogs(results)}`);
2001
2079
  pushCtx({ type: "changelogs", results });
2080
+ await selectFromPager(` ${bold("Changelogs")}${version ? ` for ${bold(version)}` : ""}${breakingOnly ? ` ${red("(breaking only)")}` : ""}\n\n${renderChangelogs(results)}`, results.length);
2002
2081
  }
2003
2082
 
2004
2083
  async function doSearchVideos(query: string): Promise<void> {
@@ -2007,8 +2086,11 @@ async function doSearchVideos(query: string): Promise<void> {
2007
2086
  console.log(dim(` No video results for "${query}".`));
2008
2087
  return;
2009
2088
  }
2010
- await paged(` ${bold(String(results.length))} video results for ${cyan(`"${query}"`)}\n\n${renderVideos(results)}`);
2011
2089
  pushCtx({ type: "videos", query, results });
2090
+ await selectFromPager(
2091
+ ` ${bold(String(results.length))} video results for ${cyan(`"${query}"`)}\n\n${renderVideos(results)}`,
2092
+ results.length,
2093
+ );
2012
2094
  }
2013
2095
 
2014
2096
  async function doSearchDude(query: string): Promise<void> {
@@ -2017,8 +2099,11 @@ async function doSearchDude(query: string): Promise<void> {
2017
2099
  console.log(dim(` No Dude wiki results for "${query}".`));
2018
2100
  return;
2019
2101
  }
2020
- await paged(` ${bold(String(results.length))} Dude wiki results for ${cyan(`"${query}"`)}\n\n${renderDudeResults(results)}`);
2021
2102
  pushCtx({ type: "dude", query, results });
2103
+ await selectFromPager(
2104
+ ` ${bold(String(results.length))} Dude wiki results for ${cyan(`"${query}"`)}\n\n${renderDudeResults(results)}`,
2105
+ results.length,
2106
+ );
2022
2107
  }
2023
2108
 
2024
2109
  async function doListSkills(): Promise<void> {
@@ -2037,8 +2122,8 @@ async function doListSkills(): Promise<void> {
2037
2122
  });
2038
2123
  out.push("");
2039
2124
  out.push(` ${dim("Type a number to view, or: skill <name>")}`);
2040
- await paged(out.join("\n"));
2041
2125
  pushCtx({ type: "skills" });
2126
+ await selectFromPager(out.join("\n"), skills.length);
2042
2127
  }
2043
2128
 
2044
2129
  async function doViewSkill(name: string): Promise<void> {
@@ -2156,6 +2241,14 @@ async function main() {
2156
2241
  } catch (err) {
2157
2242
  console.error(red(` Error: ${err instanceof Error ? err.message : String(err)}`));
2158
2243
  }
2244
+ // Discard any keystrokes that leaked into readline's internal line buffer
2245
+ // while the pager was running in raw mode. readline's data handler stays
2246
+ // active during paging, so each pager keystroke (digits, q, space, b…)
2247
+ // accumulates in rl.line and reappears echoed after the next prompt via
2248
+ // _refreshLine(). Clear before re-prompting to keep the prompt clean.
2249
+ const rlBuf = rl as unknown as { line: string; cursor: number };
2250
+ rlBuf.line = "";
2251
+ rlBuf.cursor = 0;
2159
2252
  rl.setPrompt(buildPrompt());
2160
2253
  rl.prompt();
2161
2254
  });
@@ -0,0 +1,193 @@
1
+ // Set BEFORE importing extract-test-results.ts (which transitively imports
2
+ // db.ts). Prevents this test file from pinning the DB singleton to a real
3
+ // ros-help.db path before query.test.ts can enforce its in-memory guard.
4
+ process.env.DB_PATH = ":memory:";
5
+
6
+ import { describe, expect, it } from "bun:test";
7
+ import { parseHTML } from "linkedom";
8
+
9
+ // Dynamic import so the DB_PATH assignment above is visible before db.ts loads.
10
+ const { parsePerformanceTable } = await import("./extract-test-results.ts");
11
+
12
+ // ── Fixture helpers ──────────────────────────────────────────────────────────
13
+
14
+ /** Build a minimal performance-table HTML matching the real MikroTik page structure. */
15
+ function makeEthernetTable(rows: string[]): Element {
16
+ const html = `
17
+ <table class="performance-table">
18
+ <thead>
19
+ <tr>
20
+ <td>RB1100Dx4</td>
21
+ <td>AL21400 1G all port test</td>
22
+ </tr>
23
+ <tr>
24
+ <td>Mode</td>
25
+ <td>Configuration</td>
26
+ <td>1518 byte</td>
27
+ <td></td>
28
+ <td>512 byte</td>
29
+ <td></td>
30
+ <td>64 byte</td>
31
+ <td></td>
32
+ </tr>
33
+ <tr>
34
+ <td></td><td></td>
35
+ <td>kpps</td><td>Mbps</td>
36
+ <td>kpps</td><td>Mbps</td>
37
+ <td>kpps</td><td>Mbps</td>
38
+ </tr>
39
+ </thead>
40
+ <tbody>
41
+ ${rows.join("\n ")}
42
+ </tbody>
43
+ </table>`;
44
+ const { document } = parseHTML(`<html><body>${html}</body></html>`);
45
+ const table = document.querySelector("table");
46
+ if (!table) throw new Error("fixture build failed");
47
+ return table;
48
+ }
49
+
50
+ function makeIpsecTable(rows: string[]): Element {
51
+ const html = `
52
+ <table class="performance-table">
53
+ <thead>
54
+ <tr>
55
+ <td>RB1100Dx4</td>
56
+ <td>IPsec AES hardware acceleration</td>
57
+ </tr>
58
+ <tr>
59
+ <td>Mode</td>
60
+ <td>Configuration</td>
61
+ <td>1400 byte</td>
62
+ <td></td>
63
+ <td>512 byte</td>
64
+ <td></td>
65
+ <td>64 byte</td>
66
+ <td></td>
67
+ </tr>
68
+ <tr>
69
+ <td></td><td></td>
70
+ <td>kpps</td><td>Mbps</td>
71
+ <td>kpps</td><td>Mbps</td>
72
+ <td>kpps</td><td>Mbps</td>
73
+ </tr>
74
+ </thead>
75
+ <tbody>
76
+ ${rows.join("\n ")}
77
+ </tbody>
78
+ </table>`;
79
+ const { document } = parseHTML(`<html><body>${html}</body></html>`);
80
+ const table = document.querySelector("table");
81
+ if (!table) throw new Error("fixture build failed");
82
+ return table;
83
+ }
84
+
85
+ // ── Tests ────────────────────────────────────────────────────────────────────
86
+
87
+ describe("parsePerformanceTable", () => {
88
+ describe("type detection", () => {
89
+ it("detects ethernet type from header", () => {
90
+ const table = makeEthernetTable([
91
+ "<tr><td>Bridging</td><td>none (fast path)</td><td>606.5</td><td>7,365.3</td><td>1,736.4</td><td>7,112.3</td><td>5,509.7</td><td>2,821.0</td></tr>",
92
+ ]);
93
+ const { testType } = parsePerformanceTable(table);
94
+ expect(testType).toBe("ethernet");
95
+ });
96
+
97
+ it("detects ipsec type from header", () => {
98
+ const table = makeIpsecTable([
99
+ "<tr><td>Single tunnel</td><td>AES-128-CBC + SHA1</td><td>92.1</td><td>1,031.5</td><td>93.1</td><td>381.3</td><td>92.3</td><td>47.3</td></tr>",
100
+ ]);
101
+ const { testType } = parsePerformanceTable(table);
102
+ expect(testType).toBe("ipsec");
103
+ });
104
+ });
105
+
106
+ describe("thousands-separator handling", () => {
107
+ it("correctly parses values with comma thousands separators (regression: RB1100Dx4 512-byte row)", () => {
108
+ // Website: Bridging, none (fast path), 512B → kpps=1,736.4, Mbps=7,112.3
109
+ // Before fix: parseFloat("1,736.4") → 1, parseFloat("7,112.3") → 7
110
+ const table = makeEthernetTable([
111
+ "<tr><td>Bridging</td><td>none (fast path)</td><td>606.5</td><td>7,365.3</td><td>1,736.4</td><td>7,112.3</td><td>5,509.7</td><td>2,821.0</td></tr>",
112
+ ]);
113
+ const { rows } = parsePerformanceTable(table);
114
+
115
+ const row512 = rows.find((r) => r.packet_size === 512);
116
+ expect(row512).toBeDefined();
117
+ expect(row512?.throughput_kpps).toBeCloseTo(1736.4);
118
+ expect(row512?.throughput_mbps).toBeCloseTo(7112.3);
119
+ });
120
+
121
+ it("correctly parses 1518-byte row with large comma-formatted values", () => {
122
+ const table = makeEthernetTable([
123
+ "<tr><td>Bridging</td><td>none (fast path)</td><td>606.5</td><td>7,365.3</td><td>1,736.4</td><td>7,112.3</td><td>5,509.7</td><td>2,821.0</td></tr>",
124
+ ]);
125
+ const { rows } = parsePerformanceTable(table);
126
+
127
+ const row1518 = rows.find((r) => r.packet_size === 1518);
128
+ expect(row1518?.throughput_kpps).toBeCloseTo(606.5);
129
+ expect(row1518?.throughput_mbps).toBeCloseTo(7365.3);
130
+ });
131
+
132
+ it("correctly parses 64-byte row with large comma-formatted kpps", () => {
133
+ const table = makeEthernetTable([
134
+ "<tr><td>Bridging</td><td>none (fast path)</td><td>606.5</td><td>7,365.3</td><td>1,736.4</td><td>7,112.3</td><td>5,509.7</td><td>2,821.0</td></tr>",
135
+ ]);
136
+ const { rows } = parsePerformanceTable(table);
137
+
138
+ const row64 = rows.find((r) => r.packet_size === 64);
139
+ expect(row64?.throughput_kpps).toBeCloseTo(5509.7);
140
+ expect(row64?.throughput_mbps).toBeCloseTo(2821.0);
141
+ });
142
+
143
+ it("still handles values without thousands separators", () => {
144
+ const table = makeEthernetTable([
145
+ "<tr><td>Routing</td><td>25 simple queues</td><td>606.5</td><td>7365.3</td><td>933.6</td><td>3824.0</td><td>960.3</td><td>491.7</td></tr>",
146
+ ]);
147
+ const { rows } = parsePerformanceTable(table);
148
+
149
+ const row512 = rows.find((r) => r.packet_size === 512);
150
+ expect(row512?.throughput_kpps).toBeCloseTo(933.6);
151
+ expect(row512?.throughput_mbps).toBeCloseTo(3824.0);
152
+ });
153
+ });
154
+
155
+ describe("row structure", () => {
156
+ it("emits one row per packet size", () => {
157
+ const table = makeEthernetTable([
158
+ "<tr><td>Bridging</td><td>none (fast path)</td><td>606.5</td><td>7,365.3</td><td>1,736.4</td><td>7,112.3</td><td>5,509.7</td><td>2,821.0</td></tr>",
159
+ ]);
160
+ const { rows } = parsePerformanceTable(table);
161
+ expect(rows).toHaveLength(3); // 1518, 512, 64
162
+ });
163
+
164
+ it("preserves mode and configuration strings", () => {
165
+ const table = makeEthernetTable([
166
+ "<tr><td>Routing</td><td>25 ip filter rules</td><td>543.7</td><td>6,602.7</td><td>561.8</td><td>2,301.1</td><td>564.6</td><td>289.1</td></tr>",
167
+ ]);
168
+ const { rows } = parsePerformanceTable(table);
169
+ expect(rows[0].mode).toBe("Routing");
170
+ expect(rows[0].configuration).toBe("25 ip filter rules");
171
+ });
172
+
173
+ it("returns null for unparseable cell values", () => {
174
+ const table = makeEthernetTable([
175
+ "<tr><td>Bridging</td><td>none (fast path)</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td><td>-</td></tr>",
176
+ ]);
177
+ const { rows } = parsePerformanceTable(table);
178
+ expect(rows[0].throughput_kpps).toBeNull();
179
+ expect(rows[0].throughput_mbps).toBeNull();
180
+ });
181
+
182
+ it("parses ipsec rows with 1400/512/64 packet sizes", () => {
183
+ const table = makeIpsecTable([
184
+ "<tr><td>Single tunnel</td><td>AES-128-CBC + SHA1</td><td>92.1</td><td>1,031.5</td><td>93.1</td><td>381.3</td><td>92.3</td><td>47.3</td></tr>",
185
+ ]);
186
+ const { rows } = parsePerformanceTable(table);
187
+ const sizes = rows.map((r) => r.packet_size);
188
+ expect(sizes).toEqual([1400, 512, 64]);
189
+ const row1400 = rows.find((r) => r.packet_size === 1400);
190
+ expect(row1400?.throughput_mbps).toBeCloseTo(1031.5);
191
+ });
192
+ });
193
+ });
@@ -19,7 +19,6 @@
19
19
  */
20
20
 
21
21
  import { parseHTML } from "linkedom";
22
- import { db, initDb } from "./db.ts";
23
22
 
24
23
  // ── CLI flags ──
25
24
 
@@ -58,7 +57,7 @@ const SLUG_OVERRIDES: Record<string, string> = {
58
57
 
59
58
  // ── Types ──
60
59
 
61
- interface TestResultRow {
60
+ export interface TestResultRow {
62
61
  mode: string;
63
62
  configuration: string;
64
63
  packet_size: number;
@@ -76,7 +75,7 @@ interface ProductPageData {
76
75
  // ── HTML Parsing ──
77
76
 
78
77
  /** Parse a performance-table element into test result rows. */
79
- function parsePerformanceTable(table: Element): { testType: string; rows: TestResultRow[] } {
78
+ export function parsePerformanceTable(table: Element): { testType: string; rows: TestResultRow[] } {
80
79
  const rows: TestResultRow[] = [];
81
80
 
82
81
  // Header row: first <tr> in <thead> has [product_code, test_description]
@@ -124,14 +123,16 @@ function parsePerformanceTable(table: Element): { testType: string; rows: TestRe
124
123
  const config = (cells[1].textContent || "").trim();
125
124
 
126
125
  // Each packet size has 2 columns: kpps, Mbps
126
+ // Strip thousands-separator commas before parsing (e.g. "7,112.3" → 7112.3)
127
+ const parseNum = (s: string) => Number.parseFloat(s.replace(/,/g, "").trim());
127
128
  for (let i = 0; i < packetSizes.length; i++) {
128
129
  const kppsIdx = 2 + i * 2;
129
130
  const mbpsIdx = 3 + i * 2;
130
131
  if (kppsIdx >= cells.length) break;
131
132
 
132
- const kpps = Number.parseFloat((cells[kppsIdx].textContent || "").trim());
133
+ const kpps = parseNum((cells[kppsIdx].textContent || "").trim());
133
134
  const mbps = mbpsIdx < cells.length
134
- ? Number.parseFloat((cells[mbpsIdx].textContent || "").trim())
135
+ ? parseNum((cells[mbpsIdx].textContent || "").trim())
135
136
  : null;
136
137
 
137
138
  rows.push({
@@ -309,124 +310,131 @@ function sleep(ms: number): Promise<void> {
309
310
  }
310
311
 
311
312
  // ── Main ──
313
+ async function main(): Promise<void> {
314
+ const { db, initDb } = await import("./db.ts");
312
315
 
313
- initDb();
316
+ initDb();
314
317
 
315
- // Get all devices from DB
316
- const devices = db.prepare("SELECT id, product_name, product_code FROM devices ORDER BY product_name").all() as Array<{
317
- id: number;
318
- product_name: string;
319
- product_code: string | null;
320
- }>;
318
+ // Get all devices from DB
319
+ const devices = db.prepare("SELECT id, product_name, product_code FROM devices ORDER BY product_name").all() as Array<{
320
+ id: number;
321
+ product_name: string;
322
+ product_code: string | null;
323
+ }>;
321
324
 
322
- if (devices.length === 0) {
323
- console.error("No devices in database. Run extract-devices.ts first.");
324
- process.exit(1);
325
- }
326
-
327
- console.log(`Found ${devices.length} devices in database`);
328
-
329
- // Fetch sitemap once to validate slugs and prioritize correct candidates
330
- console.log("Loading product sitemap...");
331
- const sitemapSlugs = await fetchSitemap();
332
-
333
- // Build device → candidate slugs mapping
334
- const deviceSlugs: Array<{ id: number; name: string; slugs: string[] }> = [];
335
- for (const dev of devices) {
336
- const slugs = buildSlugCandidates(dev.product_name, dev.product_code, sitemapSlugs);
337
- deviceSlugs.push({ id: dev.id, name: dev.product_name, slugs });
338
- }
339
-
340
- // Idempotent: clear existing test results
341
- db.run("DELETE FROM device_test_results");
342
-
343
- // Prepare statements
344
- const insertTest = db.prepare(`INSERT OR IGNORE INTO device_test_results (
345
- device_id, test_type, mode, configuration, packet_size,
346
- throughput_kpps, throughput_mbps
347
- ) VALUES (?, ?, ?, ?, ?, ?, ?)`);
348
-
349
- const updateDevice = db.prepare(`UPDATE devices
350
- SET product_url = ?, block_diagram_url = ?
351
- WHERE id = ?`);
352
-
353
- console.log(`Fetching ${deviceSlugs.length} product pages (concurrency=${CONCURRENCY}, delay=${DELAY_MS}ms)...`);
354
-
355
- let totalTests = 0;
356
- let devicesWithTests = 0;
357
- let devicesWithDiagrams = 0;
358
- let fetchErrors = 0;
359
-
360
- const insertAll = db.transaction(
361
- (results: Array<{ deviceId: number; data: ProductPageData | null }>) => {
362
- for (const { deviceId, data } of results) {
363
- if (!data) {
364
- fetchErrors++;
365
- continue;
366
- }
367
-
368
- // Update device with URL and block diagram
369
- updateDevice.run(
370
- `https://mikrotik.com/product/${data.slug}`,
371
- data.block_diagram_url,
372
- deviceId,
373
- );
325
+ if (devices.length === 0) {
326
+ console.error("No devices in database. Run extract-devices.ts first.");
327
+ process.exit(1);
328
+ }
374
329
 
375
- if (data.block_diagram_url) devicesWithDiagrams++;
330
+ console.log(`Found ${devices.length} devices in database`);
376
331
 
377
- // Insert test results
378
- const allResults = [
379
- ...data.ethernet_results.map((r) => ({ ...r, test_type: "ethernet" as const })),
380
- ...data.ipsec_results.map((r) => ({ ...r, test_type: "ipsec" as const })),
381
- ];
332
+ // Fetch sitemap once to validate slugs and prioritize correct candidates
333
+ console.log("Loading product sitemap...");
334
+ const sitemapSlugs = await fetchSitemap();
382
335
 
383
- if (allResults.length > 0) devicesWithTests++;
336
+ // Build device -> candidate slugs mapping
337
+ const deviceSlugs: Array<{ id: number; name: string; slugs: string[] }> = [];
338
+ for (const dev of devices) {
339
+ const slugs = buildSlugCandidates(dev.product_name, dev.product_code, sitemapSlugs);
340
+ deviceSlugs.push({ id: dev.id, name: dev.product_name, slugs });
341
+ }
384
342
 
385
- for (const r of allResults) {
386
- insertTest.run(
343
+ // Idempotent: clear existing test results
344
+ db.run("DELETE FROM device_test_results");
345
+
346
+ // Prepare statements
347
+ const insertTest = db.prepare(`INSERT OR IGNORE INTO device_test_results (
348
+ device_id, test_type, mode, configuration, packet_size,
349
+ throughput_kpps, throughput_mbps
350
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`);
351
+
352
+ const updateDevice = db.prepare(`UPDATE devices
353
+ SET product_url = ?, block_diagram_url = ?
354
+ WHERE id = ?`);
355
+
356
+ console.log(`Fetching ${deviceSlugs.length} product pages (concurrency=${CONCURRENCY}, delay=${DELAY_MS}ms)...`);
357
+
358
+ let totalTests = 0;
359
+ let devicesWithTests = 0;
360
+ let devicesWithDiagrams = 0;
361
+ let fetchErrors = 0;
362
+
363
+ const insertAll = db.transaction(
364
+ (results: Array<{ deviceId: number; data: ProductPageData | null }>) => {
365
+ for (const { deviceId, data } of results) {
366
+ if (!data) {
367
+ fetchErrors++;
368
+ continue;
369
+ }
370
+
371
+ // Update device with URL and block diagram
372
+ updateDevice.run(
373
+ `https://mikrotik.com/product/${data.slug}`,
374
+ data.block_diagram_url,
387
375
  deviceId,
388
- r.test_type,
389
- r.mode,
390
- r.configuration,
391
- r.packet_size,
392
- r.throughput_kpps,
393
- r.throughput_mbps,
394
376
  );
395
- totalTests++;
377
+
378
+ if (data.block_diagram_url) devicesWithDiagrams++;
379
+
380
+ // Insert test results
381
+ const allResults = [
382
+ ...data.ethernet_results.map((r) => ({ ...r, test_type: "ethernet" as const })),
383
+ ...data.ipsec_results.map((r) => ({ ...r, test_type: "ipsec" as const })),
384
+ ];
385
+
386
+ if (allResults.length > 0) devicesWithTests++;
387
+
388
+ for (const r of allResults) {
389
+ insertTest.run(
390
+ deviceId,
391
+ r.test_type,
392
+ r.mode,
393
+ r.configuration,
394
+ r.packet_size,
395
+ r.throughput_kpps,
396
+ r.throughput_mbps,
397
+ );
398
+ totalTests++;
399
+ }
396
400
  }
397
- }
398
- },
399
- );
400
-
401
- // Fetch all products with rate limiting
402
- const allResults: Array<{ deviceId: number; data: ProductPageData | null }> = [];
403
- let processed = 0;
404
-
405
- for (let i = 0; i < deviceSlugs.length; i += CONCURRENCY) {
406
- const batch = deviceSlugs.slice(i, i + CONCURRENCY);
407
- const batchResults = await Promise.all(
408
- batch.map(async (dev) => {
409
- const data = await fetchProductPage(dev.slugs);
410
- return { deviceId: dev.id, data };
411
- }),
401
+ },
412
402
  );
413
- allResults.push(...batchResults);
414
- processed += batch.length;
415
403
 
416
- const pct = Math.round((processed / deviceSlugs.length) * 100);
417
- process.stdout.write(`\r ${processed}/${deviceSlugs.length} (${pct}%)`);
404
+ // Fetch all products with rate limiting
405
+ const allResults: Array<{ deviceId: number; data: ProductPageData | null }> = [];
406
+ let processed = 0;
407
+
408
+ for (let i = 0; i < deviceSlugs.length; i += CONCURRENCY) {
409
+ const batch = deviceSlugs.slice(i, i + CONCURRENCY);
410
+ const batchResults = await Promise.all(
411
+ batch.map(async (dev) => {
412
+ const data = await fetchProductPage(dev.slugs);
413
+ return { deviceId: dev.id, data };
414
+ }),
415
+ );
416
+ allResults.push(...batchResults);
417
+ processed += batch.length;
418
+
419
+ const pct = Math.round((processed / deviceSlugs.length) * 100);
420
+ process.stdout.write(`\r ${processed}/${deviceSlugs.length} (${pct}%)`);
421
+
422
+ if (i + CONCURRENCY < deviceSlugs.length) {
423
+ await sleep(DELAY_MS);
424
+ }
425
+ }
426
+ console.log(""); // newline after progress
418
427
 
419
- if (i + CONCURRENCY < deviceSlugs.length) {
420
- await sleep(DELAY_MS);
428
+ // Insert all results in one transaction
429
+ insertAll(allResults);
430
+
431
+ console.log(`Test results: ${totalTests} rows for ${devicesWithTests} devices`);
432
+ console.log(`Block diagrams: ${devicesWithDiagrams} devices`);
433
+ if (fetchErrors > 0) {
434
+ console.warn(`Fetch errors: ${fetchErrors} products`);
421
435
  }
422
436
  }
423
- console.log(""); // newline after progress
424
-
425
- // Insert all results in one transaction
426
- insertAll(allResults);
427
437
 
428
- console.log(`Test results: ${totalTests} rows for ${devicesWithTests} devices`);
429
- console.log(`Block diagrams: ${devicesWithDiagrams} devices`);
430
- if (fetchErrors > 0) {
431
- console.warn(`Fetch errors: ${fetchErrors} products`);
438
+ if (import.meta.main) {
439
+ await main();
432
440
  }
package/src/mcp.ts CHANGED
@@ -1288,6 +1288,11 @@ what would otherwise require 125+ individual device lookups.
1288
1288
  **Configuration matching:** Uses LIKE (substring) — "25 ip filter" matches "25 ip filter rules".
1289
1289
  Note: some devices use slightly different names (e.g., "25 bridge filter" vs "25 bridge filter rules").
1290
1290
 
1291
+ **Default ordering:** Results are sorted by throughput DESC. When \`packet_size\` is NOT specified,
1292
+ 512-byte rows are surfaced first (within the LIMIT) before other sizes — 512B is the conventional
1293
+ mid-size benchmark RouterOS admins compare on, so this keeps comparable values from being crowded
1294
+ out by 1518B "best case" rows. Pin \`packet_size\` to override.
1295
+
1291
1296
  **Tip:** Call with no filters first to see available test_types, modes, configurations, and packet_sizes via the metadata field.
1292
1297
 
1293
1298
  Results include product_name, product_code, architecture — use routeros_device_lookup for full specs (CPU, RAM, ports, etc.).
package/src/query.test.ts CHANGED
@@ -1375,6 +1375,30 @@ describe("searchDeviceTests", () => {
1375
1375
  expect(res.results).toHaveLength(1);
1376
1376
  expect(res.total).toBe(4);
1377
1377
  });
1378
+
1379
+ test("prioritizes 512-byte rows when packet_size is unspecified", () => {
1380
+ // Fixtures: two 512B ethernet rows + one 1518B ethernet row (higher mbps) + one 1400B ipsec row.
1381
+ // Without 512B priority, the 1518B/19577 mbps row would lead. With priority, 512B rows lead.
1382
+ const res = searchDeviceTests({ test_type: "ethernet" });
1383
+ expect(res.results.length).toBeGreaterThanOrEqual(3);
1384
+ // All 512B rows must precede any non-512B row
1385
+ let sawNon512 = false;
1386
+ for (const row of res.results) {
1387
+ if (row.packet_size !== 512) sawNon512 = true;
1388
+ else if (sawNon512) {
1389
+ throw new Error(`512B row appeared after a non-512B row at packet_size=${row.packet_size}`);
1390
+ }
1391
+ }
1392
+ // Within the 512B bucket, mbps descending
1393
+ expect(res.results[0].packet_size).toBe(512);
1394
+ expect(res.results[0].throughput_mbps).toBe(9551.9);
1395
+ });
1396
+
1397
+ test("explicit packet_size filter overrides 512B priority", () => {
1398
+ const res = searchDeviceTests({ packet_size: 1518 });
1399
+ expect(res.results).toHaveLength(1);
1400
+ expect(res.results[0].packet_size).toBe(1518);
1401
+ });
1378
1402
  });
1379
1403
 
1380
1404
  describe("dataset CSV exports", () => {
package/src/query.ts CHANGED
@@ -1701,13 +1701,23 @@ export function searchDeviceTests(
1701
1701
  JOIN devices d ON d.id = t.device_id ${whereClause}`;
1702
1702
  const total = Number((db.prepare(totalSql).get(...params) as { c: number }).c);
1703
1703
 
1704
+ // When the caller did NOT pin a packet size, prioritise 512-byte results.
1705
+ // 512B is the conventional "mid-size" benchmark RouterOS admins compare on
1706
+ // — it best represents real-world traffic mixes. Without this, the default
1707
+ // throughput-DESC sort surfaces 1518B "best case" rows first, crowding out
1708
+ // the comparison values within the LIMIT. Within each priority bucket we
1709
+ // still sort by throughput DESC so the fastest devices float to the top.
1710
+ const orderBy = filters.packet_size === undefined
1711
+ ? `(CASE WHEN t.packet_size = 512 THEN 0 ELSE 1 END), ${orderCol} DESC NULLS LAST`
1712
+ : `${orderCol} DESC NULLS LAST`;
1713
+
1704
1714
  const sql = `SELECT d.product_name, d.product_code, d.architecture,
1705
1715
  t.test_type, t.mode, t.configuration, t.packet_size,
1706
1716
  t.throughput_kpps, t.throughput_mbps
1707
1717
  FROM device_test_results t
1708
1718
  JOIN devices d ON d.id = t.device_id
1709
1719
  ${whereClause}
1710
- ORDER BY ${orderCol} DESC NULLS LAST
1720
+ ORDER BY ${orderBy}
1711
1721
  LIMIT ?`;
1712
1722
 
1713
1723
  const results = db.prepare(sql).all(...params, limit) as DeviceTestRow[];
@@ -454,6 +454,51 @@ describe("CLI flags", () => {
454
454
  });
455
455
  });
456
456
 
457
+ // ---------------------------------------------------------------------------
458
+ // Browse TUI structural checks — catch pager/navigation regressions at build time
459
+ // ---------------------------------------------------------------------------
460
+
461
+ describe("browse TUI structure", () => {
462
+ const src = readText("src/browse.ts");
463
+ const changelogPrefix = 'await selectFromPager(` ' + "$" + '{bold("Changelogs")}';
464
+
465
+ test("defines helper to route pager digit selections back into current context", () => {
466
+ expect(src).toContain("async function selectFromPager");
467
+ expect(src).toContain("handleNumberSelect(result.selected - 1)");
468
+ });
469
+
470
+ test("re-rendered result views preserve pager selection", () => {
471
+ expect(src).toContain('await selectFromPager(renderSearchResults(ctx.response), ctx.results.length)');
472
+ expect(src).toContain('await selectFromPager(renderCommandTree(ctx.path, children), children.length)');
473
+ expect(src).toContain('await selectFromPager(renderCallouts(ctx.results), ctx.results.length)');
474
+ expect(src).toContain('await selectFromPager(renderChangelogs(ctx.results), ctx.results.length)');
475
+ });
476
+
477
+ test("initial changelog and page-callout views push context before pager selection", () => {
478
+ expect(src).toContain('pushCtx({ type: "changelogs", results });');
479
+ expect(src).toContain(changelogPrefix);
480
+ expect(src).toContain('pushCtx({ type: "callouts", query: "", results: pageCallouts });');
481
+ expect(src).toContain('await selectFromPager(renderCallouts(pageCallouts), pageCallouts.length);');
482
+ });
483
+
484
+ test("page-scoped [p] and [cal] work on both page and sections contexts", () => {
485
+ // Pages with headings push type:"sections" not type:"page", so both handlers
486
+ // must check for sections context to make the hints visible on the page work.
487
+ expect(src).toContain('ctx.type === "page" || ctx.type === "sections"');
488
+ // Both handlers must appear at least twice (one per fix)
489
+ const matches = src.split('ctx.type === "page" || ctx.type === "sections"').length - 1;
490
+ expect(matches).toBeGreaterThanOrEqual(2);
491
+ });
492
+
493
+ test("pager keystrokes cleared from readline buffer before re-prompt", () => {
494
+ // readline's data handler stays active while pager runs in raw mode, so
495
+ // pager keystrokes accumulate in rl.line and appear after the next prompt.
496
+ // The REPL line handler must clear rl.line/cursor before calling rl.prompt().
497
+ expect(src).toContain('rlBuf.line = ""');
498
+ expect(src).toContain("rlBuf.cursor = 0");
499
+ });
500
+ });
501
+
457
502
  // ---------------------------------------------------------------------------
458
503
  // HTTP transport structural checks — catch per-session breakage at build time
459
504
  // ---------------------------------------------------------------------------