@tikoci/rosetta 0.2.0

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,994 @@
1
+ /**
2
+ * query.test.ts — Tests for the NL→FTS5 query planner and DB query functions.
3
+ *
4
+ * Pure-function tests (extractTerms, buildFtsQuery) need no database.
5
+ * Integration tests use an in-memory SQLite seeded with fixture data.
6
+ *
7
+ * DB_PATH must be set BEFORE db.ts is first imported; dynamic imports
8
+ * ensure this env-var assignment wins over Bun's static-import hoisting.
9
+ */
10
+ import { beforeAll, describe, expect, test } from "bun:test";
11
+
12
+ // Set BEFORE any import that transitively loads db.ts
13
+ process.env.DB_PATH = ":memory:";
14
+
15
+ // Dynamic imports so the env-var assignment above is visible to db.ts
16
+ const { db, initDb } = await import("./db.ts");
17
+ const {
18
+ extractTerms,
19
+ buildFtsQuery,
20
+ searchPages,
21
+ getPage,
22
+ lookupProperty,
23
+ browseCommands,
24
+ searchCallouts,
25
+ searchChangelogs,
26
+ checkCommandVersions,
27
+ searchDevices,
28
+ } = await import("./query.ts");
29
+ const { parseChangelog } = await import("./extract-changelogs.ts");
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Fixtures: one "DHCP Server" page + one "Firewall Filter" page
33
+ // ---------------------------------------------------------------------------
34
+
35
+ beforeAll(() => {
36
+ initDb();
37
+
38
+ db.run(`INSERT INTO pages
39
+ (id, slug, title, path, depth, parent_id, url, text, code, code_lang,
40
+ author, last_updated, word_count, code_lines, html_file)
41
+ VALUES
42
+ (1, 'DHCP', 'DHCP Server', 'RouterOS > IP > DHCP Server', 2, NULL,
43
+ 'https://help.mikrotik.com/docs/spaces/ROS/pages/1/DHCP',
44
+ 'DHCP server assigns IP addresses to clients on the local network. Configure lease time and address pool.',
45
+ '/ip dhcp-server add name=myserver', NULL, NULL, NULL, 20, 1, 'test.html')`);
46
+
47
+ db.run(`INSERT INTO pages
48
+ (id, slug, title, path, depth, parent_id, url, text, code, code_lang,
49
+ author, last_updated, word_count, code_lines, html_file)
50
+ VALUES
51
+ (2, 'Firewall-Filter', 'Firewall Filter', 'RouterOS > IP > Firewall', 2, NULL,
52
+ 'https://help.mikrotik.com/docs/spaces/ROS/pages/2/Firewall-Filter',
53
+ 'Firewall filter rules control packet forwarding and processing. Add chains and actions.',
54
+ '/ip firewall filter add chain=forward action=drop', NULL, NULL, NULL, 15, 1, 'test2.html')`);
55
+
56
+ db.run(`INSERT INTO callouts (id, page_id, type, content, sort_order)
57
+ VALUES (1, 1, 'Note', 'DHCP lease time defaults to 10 minutes on a fresh install', 0)`);
58
+
59
+ db.run(`INSERT INTO callouts (id, page_id, type, content, sort_order)
60
+ VALUES (2, 2, 'Warning', 'Dropping all forward traffic will break routing', 0)`);
61
+
62
+ db.run(`INSERT INTO properties
63
+ (id, page_id, name, type, default_val, description, section, sort_order)
64
+ VALUES (1, 1, 'lease-time', 'time', '10m', 'Lease duration for DHCP clients', NULL, 0)`);
65
+
66
+ db.run(`INSERT INTO properties
67
+ (id, page_id, name, type, default_val, description, section, sort_order)
68
+ VALUES (2, 1, 'address-pool', 'string', '', 'Name of the address pool to use', NULL, 1)`);
69
+
70
+ db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
71
+ VALUES ('7.22', 'stable', 0, '2024-01-01T00:00:00Z')`);
72
+
73
+ db.run(`INSERT INTO commands
74
+ (id, path, name, type, parent_path, page_id, description, ros_version)
75
+ VALUES (1, '/ip', 'ip', 'dir', NULL, NULL, 'IP menu', '7.22')`);
76
+
77
+ db.run(`INSERT INTO commands
78
+ (id, path, name, type, parent_path, page_id, description, ros_version)
79
+ VALUES (2, '/ip/dhcp-server', 'dhcp-server', 'dir', '/ip', 1, 'DHCP Server configuration', '7.22')`);
80
+
81
+ db.run(`INSERT INTO command_versions (command_path, ros_version)
82
+ VALUES ('/ip/dhcp-server', '7.22')`);
83
+
84
+ // Device fixtures for searchDevices tests
85
+ db.run(`INSERT INTO devices
86
+ (product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
87
+ license_level, operating_system, ram, ram_mb, storage, storage_mb,
88
+ poe_in, poe_out, wireless_24_chains, wireless_5_chains,
89
+ eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
90
+ eth_multigig, usb_ports, sim_slots, msrp_usd)
91
+ VALUES
92
+ ('hAP ax3', 'C53UiG+5HPaxD2HPaxD', 'ARM 64bit', 'IPQ-6010', 4, 'auto (864 - 1800) MHz',
93
+ 4, 'RouterOS v7', '1 GB', 1024, '128 MB', 128,
94
+ '802.3af/at', NULL, 2, 2,
95
+ NULL, 4, 1, NULL, NULL,
96
+ NULL, 1, NULL, 139.00)`);
97
+
98
+ db.run(`INSERT INTO devices
99
+ (product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
100
+ license_level, operating_system, ram, ram_mb, storage, storage_mb,
101
+ poe_in, poe_out, wireless_24_chains, wireless_5_chains,
102
+ eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
103
+ eth_multigig, usb_ports, sim_slots, msrp_usd)
104
+ VALUES
105
+ ('CCR2216-1G-12XS-2XQ', 'CCR2216-1G-12XS-2XQ', 'ARM 64bit', 'AL73400', 16, '2000 MHz',
106
+ 6, 'RouterOS v7', '16 GB', 16384, '128 MB', 128,
107
+ NULL, NULL, NULL, NULL,
108
+ NULL, 1, NULL, NULL, 12,
109
+ NULL, 1, NULL, 2795.00)`);
110
+
111
+ db.run(`INSERT INTO devices
112
+ (product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
113
+ license_level, operating_system, ram, ram_mb, storage, storage_mb,
114
+ poe_in, poe_out, wireless_24_chains, wireless_5_chains,
115
+ eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
116
+ eth_multigig, usb_ports, sim_slots, msrp_usd)
117
+ VALUES
118
+ ('hAP lite', 'RB941-2nD', 'SMIPS', 'QCA9533', 1, '650 MHz',
119
+ 4, 'RouterOS', '32 MB', 32, '16 MB', 16,
120
+ NULL, NULL, 1, NULL,
121
+ 4, NULL, NULL, NULL, NULL,
122
+ NULL, NULL, NULL, 24.95)`);
123
+
124
+ db.run(`INSERT INTO devices
125
+ (product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
126
+ license_level, operating_system, ram, ram_mb, storage, storage_mb,
127
+ poe_in, poe_out, wireless_24_chains, wireless_5_chains,
128
+ eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
129
+ eth_multigig, usb_ports, sim_slots, msrp_usd)
130
+ VALUES
131
+ ('Chateau LTE18 ax', 'S53UG+5HaxD2HaxD-TC&EG18-EA', 'ARM 64bit', 'IPQ-6010', 4, 'auto (864 - 1800) MHz',
132
+ 4, 'RouterOS v7', '1 GB', 1024, '128 MB', 128,
133
+ NULL, NULL, 2, 2,
134
+ NULL, 4, 1, NULL, NULL,
135
+ NULL, 1, 2, 599.00)`);
136
+
137
+ // Device fixture: model number without hyphens (substring matching needed)
138
+ db.run(`INSERT INTO devices
139
+ (product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
140
+ license_level, operating_system, ram, ram_mb, storage, storage_mb,
141
+ poe_in, poe_out, wireless_24_chains, wireless_5_chains,
142
+ eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
143
+ eth_multigig, usb_ports, sim_slots, msrp_usd)
144
+ VALUES
145
+ ('RB1100AHx4', 'RB1100x4', 'ARM 32bit', 'AL21400', 4, '1400 MHz',
146
+ 6, 'RouterOS', '1 GB', 1024, '128 MB', 128,
147
+ NULL, NULL, NULL, NULL,
148
+ NULL, 13, NULL, NULL, NULL,
149
+ NULL, NULL, NULL, 299.00)`);
150
+
151
+ db.run(`INSERT INTO devices
152
+ (product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
153
+ license_level, operating_system, ram, ram_mb, storage, storage_mb,
154
+ poe_in, poe_out, wireless_24_chains, wireless_5_chains,
155
+ eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
156
+ eth_multigig, usb_ports, sim_slots, msrp_usd)
157
+ VALUES
158
+ ('RB1100AHx4 Dude Edition', 'RB1100Dx4', 'ARM 32bit', 'AL21400', 4, '1400 MHz',
159
+ 6, 'RouterOS', '1 GB', 1024, '512 MB', 512,
160
+ NULL, NULL, NULL, NULL,
161
+ NULL, 13, NULL, NULL, NULL,
162
+ NULL, NULL, NULL, 369.00)`);
163
+
164
+ // Page 3: a "large" page with sections for TOC testing
165
+ // Text is ~200 chars to keep fixture small, but we'll use max_length=50 to trigger truncation
166
+ db.run(`INSERT INTO pages
167
+ (id, slug, title, path, depth, parent_id, url, text, code, code_lang,
168
+ author, last_updated, word_count, code_lines, html_file)
169
+ VALUES
170
+ (3, 'Bridging', 'Bridging and Switching', 'RouterOS > Bridging', 1, NULL,
171
+ 'https://help.mikrotik.com/docs/spaces/ROS/pages/3/Bridging',
172
+ 'Bridging overview text that is moderately long for testing purposes. It covers bridge setup and VLAN configuration and STP protocol details.',
173
+ '/interface bridge add name=bridge1', NULL, NULL, NULL, 25, 1, 'test3.html')`);
174
+
175
+ db.run(`INSERT INTO sections
176
+ (id, page_id, heading, level, anchor_id, text, code, word_count, sort_order)
177
+ VALUES
178
+ (1, 3, 'Summary', 1, 'BridgingandSwitching-Summary',
179
+ 'Bridge summary text with basic overview.', '', 6, 0)`);
180
+
181
+ db.run(`INSERT INTO sections
182
+ (id, page_id, heading, level, anchor_id, text, code, word_count, sort_order)
183
+ VALUES
184
+ (2, 3, 'Bridge Interface Setup', 1, 'BridgingandSwitching-BridgeInterfaceSetup',
185
+ 'Setup instructions for bridge interfaces.', '/interface bridge add name=bridge1', 6, 1)`);
186
+
187
+ db.run(`INSERT INTO sections
188
+ (id, page_id, heading, level, anchor_id, text, code, word_count, sort_order)
189
+ VALUES
190
+ (4, 3, 'Port Configuration', 2, 'BridgingandSwitching-PortConfiguration',
191
+ 'Add ports to the bridge for switching.', '/interface bridge port add bridge=bridge1 interface=ether2', 7, 2)`);
192
+
193
+ db.run(`INSERT INTO sections
194
+ (id, page_id, heading, level, anchor_id, text, code, word_count, sort_order)
195
+ VALUES
196
+ (5, 3, 'VLAN Setup', 2, 'BridgingandSwitching-VLANSetup',
197
+ 'Configure VLANs on the bridge.', '/interface bridge vlan add bridge=bridge1 vlan-ids=10', 5, 3)`);
198
+
199
+ db.run(`INSERT INTO sections
200
+ (id, page_id, heading, level, anchor_id, text, code, word_count, sort_order)
201
+ VALUES
202
+ (3, 3, 'Spanning Tree Protocol', 1, 'BridgingandSwitching-SpanningTreeProtocol',
203
+ 'STP protocol configuration and monitoring.', '', 5, 4)`);
204
+
205
+ // Changelog fixtures
206
+ db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
207
+ VALUES ('7.21', '2025-Oct-15 12:00', 'bgp', 0, 'fixed BGP output sometimes not being cleaned after session restart', 0)`);
208
+ db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
209
+ VALUES ('7.21', '2025-Oct-15 12:00', 'bridge', 0, 'fixed performance regression in complex setups with vlan-filtering', 1)`);
210
+ db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
211
+ VALUES ('7.22', '2026-Mar-09 10:38', 'certificate', 1, 'added support for multiple ACME certificates (services that use a previously generated certificate need to be reconfigured after the certificate expires)', 0)`);
212
+ db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
213
+ VALUES ('7.22', '2026-Mar-09 10:38', 'bgp', 0, 'added BGP unnumbered support', 1)`);
214
+ db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
215
+ VALUES ('7.22', '2026-Mar-09 10:38', 'bridge', 0, 'added local and static MAC synchronization for MLAG', 2)`);
216
+ db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
217
+ VALUES ('7.22.1', '2026-Apr-01 09:00', 'wifi', 0, 'fixed channel switching for MediaTek access points', 0)`);
218
+ });
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Pure function: extractTerms
222
+ // ---------------------------------------------------------------------------
223
+
224
+ describe("extractTerms", () => {
225
+ test("lowercases and tokenises", () => {
226
+ expect(extractTerms("DHCP Server")).toEqual(["dhcp", "server"]);
227
+ });
228
+
229
+ test("removes stop words", () => {
230
+ // every word here is in the STOP_WORDS set
231
+ expect(extractTerms("how and the with without")).toEqual([]);
232
+ });
233
+
234
+ test("filters terms shorter than 2 characters", () => {
235
+ expect(extractTerms("a x y")).toEqual([]);
236
+ });
237
+
238
+ test("keeps 2-character terms", () => {
239
+ expect(extractTerms("ip route")).toEqual(["ip", "route"]);
240
+ });
241
+
242
+ test("removes punctuation but preserves hyphens", () => {
243
+ // Hyphens are preserved by the regex keep rule /[^\w\s-]/g
244
+ const terms = extractTerms("lease-time (default: 10m)");
245
+ expect(terms).toContain("lease-time");
246
+ expect(terms).not.toContain("default:");
247
+ });
248
+
249
+ test("caps at MAX_TERMS (8)", () => {
250
+ const input = "alpha bravo charlie delta echo foxtrot golf hotel india";
251
+ expect(extractTerms(input).length).toBeLessThanOrEqual(8);
252
+ });
253
+
254
+ test("returns empty array for empty string", () => {
255
+ expect(extractTerms("")).toEqual([]);
256
+ });
257
+ });
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Pure function: buildFtsQuery
261
+ // ---------------------------------------------------------------------------
262
+
263
+ describe("buildFtsQuery", () => {
264
+ test("single term produces quoted token", () => {
265
+ expect(buildFtsQuery(["dhcp"], "AND")).toBe('"dhcp"');
266
+ });
267
+
268
+ test("two unrelated terms joined with AND", () => {
269
+ expect(buildFtsQuery(["bridge", "vlan"], "AND")).toBe('NEAR("bridge" "vlan", 5)');
270
+ });
271
+
272
+ test("two unrelated terms joined with OR", () => {
273
+ // 'bridge' + 'vlan' IS a compound term → NEAR even in OR mode
274
+ // Use non-compound pair to test plain OR join
275
+ expect(buildFtsQuery(["lease", "expire"], "OR")).toBe('"lease" OR "expire"');
276
+ });
277
+
278
+ test("compound term becomes NEAR expression", () => {
279
+ // "dhcp" + "server" is a registered compound pair
280
+ const q = buildFtsQuery(["dhcp", "server"], "AND");
281
+ expect(q).toBe('NEAR("dhcp" "server", 5)');
282
+ });
283
+
284
+ test("compound term plus extra term", () => {
285
+ const q = buildFtsQuery(["firewall", "filter", "chain"], "AND");
286
+ expect(q).toContain('NEAR("firewall" "filter", 5)');
287
+ expect(q).toContain('"chain"');
288
+ });
289
+
290
+ test("empty terms array returns empty string", () => {
291
+ expect(buildFtsQuery([], "AND")).toBe("");
292
+ });
293
+ });
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // DB integration: searchPages
297
+ // ---------------------------------------------------------------------------
298
+
299
+ describe("searchPages", () => {
300
+ test("returns empty for all-stop-word query", () => {
301
+ const res = searchPages("how the what");
302
+ expect(res.results).toHaveLength(0);
303
+ expect(res.ftsQuery).toBe("");
304
+ });
305
+
306
+ test("finds DHCP page", () => {
307
+ const res = searchPages("dhcp lease");
308
+ expect(res.results.length).toBeGreaterThan(0);
309
+ expect(res.results[0].title).toBe("DHCP Server");
310
+ });
311
+
312
+ test("finds firewall page", () => {
313
+ const res = searchPages("firewall filter packet");
314
+ expect(res.results.length).toBeGreaterThan(0);
315
+ expect(res.results[0].title).toBe("Firewall Filter");
316
+ });
317
+
318
+ test("fallback OR mode when AND returns no results", () => {
319
+ // "dhcp" is in page 1, "firewall" in page 2 — AND should fail, OR should return both
320
+ const res = searchPages("dhcp firewall");
321
+ // Depending on FTS index there may or may not be an AND match; the important
322
+ // thing is that we get at least one result (OR fallback kicks in if needed)
323
+ expect(res.results.length).toBeGreaterThan(0);
324
+ });
325
+
326
+ test("respects limit parameter", () => {
327
+ const res = searchPages("server filter", 1);
328
+ expect(res.results.length).toBeLessThanOrEqual(1);
329
+ });
330
+
331
+ test("returns ftsQuery in response", () => {
332
+ const res = searchPages("dhcp server");
333
+ expect(res.ftsQuery).toBeTruthy();
334
+ expect(res.query).toBe("dhcp server");
335
+ });
336
+ });
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // DB integration: getPage
340
+ // ---------------------------------------------------------------------------
341
+
342
+ describe("getPage", () => {
343
+ test("returns null for unknown numeric ID", () => {
344
+ expect(getPage(9999)).toBeNull();
345
+ });
346
+
347
+ test("returns null for unknown title", () => {
348
+ expect(getPage("Nonexistent Page")).toBeNull();
349
+ });
350
+
351
+ test("fetches page by numeric ID", () => {
352
+ const page = getPage(1);
353
+ expect(page).not.toBeNull();
354
+ expect(page?.title).toBe("DHCP Server");
355
+ });
356
+
357
+ test("fetches page by string-encoded ID", () => {
358
+ const page = getPage("1");
359
+ expect(page?.title).toBe("DHCP Server");
360
+ });
361
+
362
+ test("fetches page by title (case-insensitive)", () => {
363
+ const page = getPage("dhcp server");
364
+ expect(page?.title).toBe("DHCP Server");
365
+ });
366
+
367
+ test("includes callouts", () => {
368
+ const page = getPage(1);
369
+ expect(page?.callouts).toHaveLength(1);
370
+ expect(page?.callouts[0].type).toBe("Note");
371
+ expect(page?.callouts[0].content).toContain("DHCP lease time");
372
+ });
373
+
374
+ test("page with no callouts returns empty callouts array", () => {
375
+ // page 2 has a callout too in our fixture; add a page with none by checking page 2
376
+ const page = getPage(2);
377
+ // page 2 has one callout in fixtures
378
+ expect(Array.isArray(page?.callouts)).toBe(true);
379
+ });
380
+
381
+ test("includes code_lines in response", () => {
382
+ const page = getPage(1);
383
+ expect(page?.code_lines).toBe(1);
384
+ });
385
+
386
+ test("truncates large pages with max_length", () => {
387
+ const full = getPage(1);
388
+ expect(full).not.toBeNull();
389
+ if (!full) return;
390
+ const fullLen = full.text.length + full.code.length;
391
+ // Request truncation well below actual page size
392
+ const truncated = getPage(1, 50);
393
+ expect(truncated).not.toBeNull();
394
+ if (!truncated) return;
395
+ expect(truncated.truncated).toBeDefined();
396
+ expect(truncated.text.length + truncated.code.length).toBeLessThan(fullLen + 100); // allow for truncation message
397
+ expect(truncated.truncated?.text_total).toBe(full.text.length);
398
+ });
399
+
400
+ test("no truncation when page fits within max_length", () => {
401
+ const page = getPage(1, 999999);
402
+ expect(page).not.toBeNull();
403
+ expect(page?.truncated).toBeUndefined();
404
+ });
405
+
406
+ test("returns TOC when page would be truncated and has sections", () => {
407
+ // Page 3 has sections + its text is ~135 chars. max_length=50 triggers truncation.
408
+ const result = getPage(3, 50);
409
+ expect(result).not.toBeNull();
410
+ if (!result) return;
411
+ expect(result.sections).toBeDefined();
412
+ expect(result.sections?.length).toBe(5);
413
+ expect(result.sections?.[0].heading).toBe("Summary");
414
+ expect(result.sections?.[0].anchor_id).toBe("BridgingandSwitching-Summary");
415
+ expect(result.sections?.[0].char_count).toBeGreaterThan(0);
416
+ expect(result.sections?.[0].url).toContain("#BridgingandSwitching-Summary");
417
+ expect(result.text).toBe("");
418
+ expect(result.note).toContain("table of contents");
419
+ expect(result.truncated).toBeDefined();
420
+ });
421
+
422
+ test("truncates normally when page has no sections", () => {
423
+ // Page 1 has no sections — should truncate the old way
424
+ const result = getPage(1, 50);
425
+ expect(result).not.toBeNull();
426
+ if (!result) return;
427
+ expect(result.sections).toBeUndefined();
428
+ expect(result.truncated).toBeDefined();
429
+ expect(result.text.length).toBeGreaterThan(0);
430
+ });
431
+
432
+ test("returns specific section by heading text", () => {
433
+ const result = getPage(3, undefined, "Summary");
434
+ expect(result).not.toBeNull();
435
+ if (!result) return;
436
+ expect(result.section).toBeDefined();
437
+ expect(result.section?.heading).toBe("Summary");
438
+ expect(result.text).toContain("Bridge summary");
439
+ expect(result.url).toContain("#BridgingandSwitching-Summary");
440
+ });
441
+
442
+ test("returns specific section by anchor_id", () => {
443
+ const result = getPage(3, undefined, "BridgingandSwitching-BridgeInterfaceSetup");
444
+ expect(result).not.toBeNull();
445
+ if (!result) return;
446
+ expect(result.section?.heading).toBe("Bridge Interface Setup");
447
+ expect(result.text).toContain("Setup instructions");
448
+ expect(result.code).toContain("bridge add");
449
+ });
450
+
451
+ test("parent section includes descendant content", () => {
452
+ // "Bridge Interface Setup" (level 1) has two level-2 children: Port Configuration, VLAN Setup
453
+ const result = getPage(3, undefined, "Bridge Interface Setup");
454
+ expect(result).not.toBeNull();
455
+ if (!result) return;
456
+ expect(result.section?.heading).toBe("Bridge Interface Setup");
457
+ // Should include own content
458
+ expect(result.text).toContain("Setup instructions");
459
+ // Should include child section content
460
+ expect(result.text).toContain("Port Configuration");
461
+ expect(result.text).toContain("Add ports to the bridge");
462
+ expect(result.text).toContain("VLAN Setup");
463
+ expect(result.text).toContain("Configure VLANs");
464
+ // Should include child code
465
+ expect(result.code).toContain("bridge port add");
466
+ expect(result.code).toContain("bridge vlan add");
467
+ // word_count sums parent + children
468
+ expect(result.word_count).toBe(6 + 7 + 5);
469
+ });
470
+
471
+ test("leaf section does not include sibling content", () => {
472
+ // "Port Configuration" (level 2) should NOT include VLAN Setup content
473
+ const result = getPage(3, undefined, "Port Configuration");
474
+ expect(result).not.toBeNull();
475
+ if (!result) return;
476
+ expect(result.section?.heading).toBe("Port Configuration");
477
+ expect(result.text).toContain("Add ports to the bridge");
478
+ expect(result.text).not.toContain("Configure VLANs");
479
+ });
480
+
481
+ test("last top-level section has no descendants", () => {
482
+ const result = getPage(3, undefined, "Spanning Tree Protocol");
483
+ expect(result).not.toBeNull();
484
+ if (!result) return;
485
+ expect(result.section?.heading).toBe("Spanning Tree Protocol");
486
+ expect(result.text).toBe("STP protocol configuration and monitoring.");
487
+ // No child content contamination
488
+ expect(result.text).not.toContain("Port Configuration");
489
+ });
490
+
491
+ test("returns section by heading case-insensitive", () => {
492
+ const result = getPage(3, undefined, "summary");
493
+ expect(result).not.toBeNull();
494
+ expect(result?.section?.heading).toBe("Summary");
495
+ });
496
+
497
+ test("returns TOC when section not found on page with sections", () => {
498
+ const result = getPage(3, undefined, "Nonexistent Section");
499
+ expect(result).not.toBeNull();
500
+ if (!result) return;
501
+ expect(result.sections).toBeDefined();
502
+ expect(result.sections?.length).toBe(5);
503
+ expect(result.note).toContain("not found");
504
+ expect(result.text).toBe("");
505
+ });
506
+
507
+ test("returns full page with note when section not found on page without sections", () => {
508
+ const result = getPage(1, undefined, "Nonexistent Section");
509
+ expect(result).not.toBeNull();
510
+ if (!result) return;
511
+ expect(result.note).toContain("no sections");
512
+ expect(result.text.length).toBeGreaterThan(0);
513
+ expect(result.sections).toBeUndefined();
514
+ });
515
+
516
+ test("section response includes callouts", () => {
517
+ const result = getPage(3, undefined, "Summary");
518
+ expect(result).not.toBeNull();
519
+ expect(Array.isArray(result?.callouts)).toBe(true);
520
+ });
521
+ });
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // DB integration: lookupProperty
525
+ // ---------------------------------------------------------------------------
526
+
527
+ describe("lookupProperty", () => {
528
+ test("finds property by exact name", () => {
529
+ const rows = lookupProperty("lease-time");
530
+ expect(rows.length).toBeGreaterThan(0);
531
+ expect(rows[0].name).toBe("lease-time");
532
+ expect(rows[0].page_title).toBe("DHCP Server");
533
+ });
534
+
535
+ test("case-insensitive name lookup", () => {
536
+ const rows = lookupProperty("LEASE-TIME");
537
+ expect(rows.length).toBeGreaterThan(0);
538
+ });
539
+
540
+ test("returns empty for unknown property", () => {
541
+ expect(lookupProperty("nonexistent-prop")).toHaveLength(0);
542
+ });
543
+
544
+ test("filters by command path", () => {
545
+ const rows = lookupProperty("lease-time", "/ip/dhcp-server");
546
+ expect(rows.length).toBeGreaterThan(0);
547
+ expect(rows[0].name).toBe("lease-time");
548
+ });
549
+
550
+ test("returns empty when command path has no linked page", () => {
551
+ const rows = lookupProperty("lease-time", "/ip/unlinked");
552
+ // /ip/unlinked has no page_id → falls through to global search
553
+ expect(Array.isArray(rows)).toBe(true);
554
+ });
555
+ });
556
+
557
+ // ---------------------------------------------------------------------------
558
+ // DB integration: browseCommands
559
+ // ---------------------------------------------------------------------------
560
+
561
+ describe("browseCommands", () => {
562
+ test("lists children of /ip", () => {
563
+ const children = browseCommands("/ip");
564
+ expect(children.length).toBeGreaterThan(0);
565
+ const paths = children.map((c) => c.path);
566
+ expect(paths).toContain("/ip/dhcp-server");
567
+ });
568
+
569
+ test("returns empty for unknown path", () => {
570
+ expect(browseCommands("/unknown/path")).toHaveLength(0);
571
+ });
572
+
573
+ test("includes linked page title", () => {
574
+ const children = browseCommands("/ip");
575
+ const dhcp = children.find((c) => c.path === "/ip/dhcp-server");
576
+ expect(dhcp?.page_title).toBe("DHCP Server");
577
+ });
578
+ });
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // DB integration: searchCallouts
582
+ // ---------------------------------------------------------------------------
583
+
584
+ describe("searchCallouts", () => {
585
+ test("finds callout by content keyword", () => {
586
+ const rows = searchCallouts("lease time");
587
+ expect(rows.length).toBeGreaterThan(0);
588
+ expect(rows[0].type).toBe("Note");
589
+ });
590
+
591
+ test("filters by type", () => {
592
+ const notes = searchCallouts("lease", "Note");
593
+ expect(notes.every((r) => r.type === "Note")).toBe(true);
594
+
595
+ const warnings = searchCallouts("dhcp", "Warning");
596
+ // no warning about dhcp in fixtures
597
+ expect(Array.isArray(warnings)).toBe(true);
598
+ });
599
+
600
+ test("returns empty for stop-word-only query", () => {
601
+ expect(searchCallouts("how the")).toHaveLength(0);
602
+ });
603
+
604
+ test("falls back to OR when AND returns nothing", () => {
605
+ // "lease" is in Note, "routing" is in Warning — no callout has both
606
+ // AND should fail, OR should find both
607
+ const rows = searchCallouts("lease routing");
608
+ expect(rows.length).toBeGreaterThan(0);
609
+ });
610
+
611
+ test("type-only browse returns callouts without query", () => {
612
+ const notes = searchCallouts("", "Note");
613
+ expect(notes.length).toBeGreaterThan(0);
614
+ expect(notes.every((r) => r.type === "Note")).toBe(true);
615
+ });
616
+ });
617
+
618
+ // ---------------------------------------------------------------------------
619
+ // DB integration: checkCommandVersions
620
+ // ---------------------------------------------------------------------------
621
+
622
+ describe("checkCommandVersions", () => {
623
+ test("returns versions for known command path", () => {
624
+ const res = checkCommandVersions("/ip/dhcp-server");
625
+ expect(res.versions).toEqual(["7.22"]);
626
+ expect(res.first_seen).toBe("7.22");
627
+ expect(res.last_seen).toBe("7.22");
628
+ });
629
+
630
+ test("returns empty versions for unknown command path", () => {
631
+ const res = checkCommandVersions("/unknown/cmd");
632
+ expect(res.versions).toHaveLength(0);
633
+ expect(res.first_seen).toBeNull();
634
+ expect(res.last_seen).toBeNull();
635
+ expect(res.note).toContain("No version data found");
636
+ });
637
+
638
+ test("includes command_path in response", () => {
639
+ const res = checkCommandVersions("/ip/dhcp-server");
640
+ expect(res.command_path).toBe("/ip/dhcp-server");
641
+ });
642
+
643
+ test("adds note when command exists at earliest tracked version", () => {
644
+ // Our fixture only has version 7.22, which is the min. Expect note.
645
+ const res = checkCommandVersions("/ip/dhcp-server");
646
+ expect(res.note).toContain("earliest tracked version");
647
+ });
648
+ });
649
+
650
+ // ---------------------------------------------------------------------------
651
+ // DB integration: searchDevices
652
+ // ---------------------------------------------------------------------------
653
+
654
+ describe("searchDevices", () => {
655
+ test("exact match by product name", () => {
656
+ const res = searchDevices("hAP ax3");
657
+ expect(res.mode).toBe("exact");
658
+ expect(res.results).toHaveLength(1);
659
+ expect(res.results[0].product_name).toBe("hAP ax3");
660
+ expect(res.results[0].ram_mb).toBe(1024);
661
+ });
662
+
663
+ test("exact match by product code", () => {
664
+ const res = searchDevices("CCR2216-1G-12XS-2XQ");
665
+ expect(res.mode).toBe("exact");
666
+ expect(res.results).toHaveLength(1);
667
+ expect(res.results[0].license_level).toBe(6);
668
+ });
669
+
670
+ test("exact match is case-insensitive", () => {
671
+ const res = searchDevices("hap ax3");
672
+ expect(res.mode).toBe("exact");
673
+ expect(res.results).toHaveLength(1);
674
+ });
675
+
676
+ test("FTS search finds devices by CPU", () => {
677
+ const res = searchDevices("IPQ-6010");
678
+ expect(res.results.length).toBeGreaterThan(0);
679
+ expect(res.results[0].product_name).toBe("hAP ax3");
680
+ });
681
+
682
+ test("FTS search by architecture keyword", () => {
683
+ const res = searchDevices("SMIPS");
684
+ expect(res.results.length).toBeGreaterThan(0);
685
+ expect(res.results[0].architecture).toBe("SMIPS");
686
+ });
687
+
688
+ test("filter by architecture", () => {
689
+ const res = searchDevices("", { architecture: "ARM 64bit" });
690
+ expect(res.mode).toBe("filter");
691
+ expect(res.results.length).toBe(3);
692
+ expect(res.results.every((d) => d.architecture === "ARM 64bit")).toBe(true);
693
+ });
694
+
695
+ test("filter by min_ram_mb", () => {
696
+ const res = searchDevices("", { min_ram_mb: 1024 });
697
+ expect(res.mode).toBe("filter");
698
+ expect(res.results.length).toBe(5); // hAP ax3 (1024) + CCR2216 (16384) + Chateau (1024) + RB1100AHx4 (1024) + RB1100AHx4 Dude (1024)
699
+ expect(res.results.every((d) => (d.ram_mb ?? 0) >= 1024)).toBe(true);
700
+ });
701
+
702
+ test("filter by license level", () => {
703
+ const res = searchDevices("", { license_level: 6 });
704
+ expect(res.mode).toBe("filter");
705
+ expect(res.results).toHaveLength(3); // CCR2216 + RB1100AHx4 + RB1100AHx4 Dude
706
+ expect(res.results.every((d) => d.license_level === 6)).toBe(true);
707
+ });
708
+
709
+ test("filter by has_poe", () => {
710
+ const res = searchDevices("", { has_poe: true });
711
+ expect(res.results).toHaveLength(1);
712
+ expect(res.results[0].product_name).toBe("hAP ax3");
713
+ });
714
+
715
+ test("filter by has_wireless", () => {
716
+ const res = searchDevices("", { has_wireless: true });
717
+ expect(res.results).toHaveLength(3); // hAP ax3 + hAP lite + Chateau LTE18
718
+ });
719
+
720
+ test("filter by min_storage_mb", () => {
721
+ const res = searchDevices("", { min_storage_mb: 128 });
722
+ expect(res.mode).toBe("filter");
723
+ expect(res.results.length).toBe(5); // hAP ax3 (128) + CCR2216 (128) + Chateau (128) + RB1100AHx4 (128) + RB1100AHx4 Dude (512)
724
+ expect(res.results.every((d) => (d.storage_mb ?? 0) >= 128)).toBe(true);
725
+ });
726
+
727
+ test("filter by min_storage_mb excludes low-storage devices", () => {
728
+ const res = searchDevices("", { min_storage_mb: 64 });
729
+ expect(res.results.every((d) => (d.storage_mb ?? 0) >= 64)).toBe(true);
730
+ // hAP lite has 16 MB, should be excluded
731
+ expect(res.results.find((d) => d.product_name === "hAP lite")).toBeUndefined();
732
+ });
733
+
734
+ test("filter by has_lte", () => {
735
+ const res = searchDevices("", { has_lte: true });
736
+ expect(res.results).toHaveLength(1);
737
+ expect(res.results[0].product_name).toBe("Chateau LTE18 ax");
738
+ expect(res.results[0].sim_slots).toBe(2);
739
+ });
740
+
741
+ test("combined FTS + filter", () => {
742
+ const res = searchDevices("hAP", { has_wireless: true });
743
+ expect(res.results.length).toBeGreaterThan(0);
744
+ expect(res.results.every((d) => d.wireless_24_chains != null || d.wireless_5_chains != null)).toBe(true);
745
+ });
746
+
747
+ test("returns empty for no match", () => {
748
+ const res = searchDevices("nonexistent-device-xyz");
749
+ expect(res.results).toHaveLength(0);
750
+ });
751
+
752
+ test("LIKE match finds substring in product name", () => {
753
+ const res = searchDevices("RB1100");
754
+ expect(res.mode).toBe("like");
755
+ expect(res.results.length).toBe(2);
756
+ expect(res.results.every((d) => d.product_name.includes("RB1100"))).toBe(true);
757
+ });
758
+
759
+ test("LIKE match finds substring in product code", () => {
760
+ const res = searchDevices("RB1100D");
761
+ expect(res.mode).toBe("like");
762
+ expect(res.results.length).toBeGreaterThanOrEqual(1);
763
+ expect(res.results[0].product_code).toBe("RB1100Dx4");
764
+ });
765
+
766
+ test("LIKE match is case-insensitive", () => {
767
+ const res = searchDevices("rb1100");
768
+ expect(res.mode).toBe("like");
769
+ expect(res.results.length).toBe(2);
770
+ });
771
+
772
+ test("natural language query finds device via LIKE", () => {
773
+ // Simulates: "get me the specs for the routerboard model RB1100"
774
+ // After stop word removal, extractTerms gets ["specs", "routerboard", "rb1100"]
775
+ // LIKE matches on "RB1100" substring in product_name
776
+ const res = searchDevices("RB1100");
777
+ expect(res.results.length).toBe(2);
778
+ expect(res.results.some((d) => d.product_name === "RB1100AHx4")).toBe(true);
779
+ expect(res.results.some((d) => d.product_name === "RB1100AHx4 Dude Edition")).toBe(true);
780
+ });
781
+
782
+ test("returns empty with no query and no filters", () => {
783
+ const res = searchDevices("");
784
+ expect(res.results).toHaveLength(0);
785
+ });
786
+ });
787
+
788
+ // ---------------------------------------------------------------------------
789
+ // Changelog Parser: parseChangelog
790
+ // ---------------------------------------------------------------------------
791
+
792
+ describe("parseChangelog", () => {
793
+ test("parses header, regular and breaking entries", () => {
794
+ const text = `What's new in 7.22 (2026-Mar-09 10:38):
795
+
796
+ !) certificate - added support for multiple ACME certificates
797
+ *) bgp - added BGP unnumbered support
798
+ *) bridge - added local and static MAC synchronization for MLAG`;
799
+
800
+ const entries = parseChangelog(text);
801
+ expect(entries).toHaveLength(3);
802
+ expect(entries[0].version).toBe("7.22");
803
+ expect(entries[0].released).toBe("2026-Mar-09 10:38");
804
+ expect(entries[0].category).toBe("certificate");
805
+ expect(entries[0].is_breaking).toBe(1);
806
+ expect(entries[1].category).toBe("bgp");
807
+ expect(entries[1].is_breaking).toBe(0);
808
+ expect(entries[2].sort_order).toBe(2);
809
+ });
810
+
811
+ test("handles multi-line continuation", () => {
812
+ const text = `What's new in 7.22 (2026-Mar-09 10:38):
813
+
814
+ *) bridge - added MLAG support per bridge interface (/interface/bridge/mlag menu is moved to
815
+ /interface/bridge; configuration is automatically updated after upgrade;
816
+ downgrading to an older version will result in MLAG configuration loss)`;
817
+
818
+ const entries = parseChangelog(text);
819
+ expect(entries).toHaveLength(1);
820
+ expect(entries[0].description).toContain("MLAG configuration loss");
821
+ expect(entries[0].description).toContain("added MLAG support");
822
+ });
823
+
824
+ test("extracts category correctly with comma-separated subsystems", () => {
825
+ const text = `What's new in 7.22 (2026-Mar-09 10:38):
826
+
827
+ *) ike1,ike2 - improved netlink update handling`;
828
+
829
+ const entries = parseChangelog(text);
830
+ expect(entries).toHaveLength(1);
831
+ expect(entries[0].category).toBe("ike1,ike2");
832
+ });
833
+
834
+ test("overrides version when expectedVersion is provided and header version differs", () => {
835
+ const text = `What's new in 7.22 (2026-Mar-09 10:38):
836
+
837
+ *) bgp - some change`;
838
+
839
+ const entries = parseChangelog(text, "7.22.1");
840
+ // Header says 7.22, expectedVersion is 7.22.1 — since no entry has version 7.22.1,
841
+ // the override applies
842
+ expect(entries[0].version).toBe("7.22.1");
843
+ });
844
+
845
+ test("returns empty for non-changelog text", () => {
846
+ const entries = parseChangelog("This is not a changelog.");
847
+ expect(entries).toHaveLength(0);
848
+ });
849
+
850
+ test("handles entry without clear category separator", () => {
851
+ const text = `What's new in 7.22 (2026-Mar-09 10:38):
852
+
853
+ *) fixed some general issue without a category separator`;
854
+
855
+ const entries = parseChangelog(text);
856
+ expect(entries).toHaveLength(1);
857
+ expect(entries[0].category).toBe("other");
858
+ });
859
+ });
860
+
861
+ // ---------------------------------------------------------------------------
862
+ // Changelog Search: searchChangelogs (DB integration)
863
+ // ---------------------------------------------------------------------------
864
+
865
+ describe("searchChangelogs", () => {
866
+ test("FTS search finds entries by keyword", () => {
867
+ const results = searchChangelogs("BGP");
868
+ expect(results.length).toBeGreaterThanOrEqual(1);
869
+ expect(results.some((r) => r.category === "bgp")).toBe(true);
870
+ });
871
+
872
+ test("version filter returns only that version", () => {
873
+ const results = searchChangelogs("", { version: "7.22" });
874
+ expect(results.length).toBeGreaterThan(0);
875
+ for (const r of results) {
876
+ expect(r.version).toBe("7.22");
877
+ }
878
+ });
879
+
880
+ test("version range filter works", () => {
881
+ const results = searchChangelogs("", { fromVersion: "7.22", toVersion: "7.22.1" });
882
+ expect(results.length).toBeGreaterThan(0);
883
+ for (const r of results) {
884
+ expect(["7.22", "7.22.1"]).toContain(r.version);
885
+ }
886
+ // Should not include 7.21
887
+ expect(results.some((r) => r.version === "7.21")).toBe(false);
888
+ });
889
+
890
+ test("category filter returns only that category", () => {
891
+ const results = searchChangelogs("", { version: "7.22", category: "bgp" });
892
+ expect(results.length).toBe(1);
893
+ expect(results[0].category).toBe("bgp");
894
+ });
895
+
896
+ test("breaking_only filter returns only breaking entries", () => {
897
+ const results = searchChangelogs("", { breakingOnly: true });
898
+ expect(results.length).toBeGreaterThan(0);
899
+ for (const r of results) {
900
+ expect(r.is_breaking).toBe(1);
901
+ }
902
+ });
903
+
904
+ test("FTS combined with version range", () => {
905
+ const results = searchChangelogs("MLAG", { fromVersion: "7.22", toVersion: "7.22" });
906
+ expect(results.length).toBe(1);
907
+ expect(results[0].category).toBe("bridge");
908
+ });
909
+
910
+ test("returns empty for non-matching query", () => {
911
+ const results = searchChangelogs("nonexistent-feature-xyz");
912
+ expect(results).toHaveLength(0);
913
+ });
914
+
915
+ test("returns empty for version with no data", () => {
916
+ const results = searchChangelogs("", { version: "6.49" });
917
+ expect(results).toHaveLength(0);
918
+ });
919
+ });
920
+
921
+ // ---------------------------------------------------------------------------
922
+ // Schema health: verify initDb creates all expected tables and triggers
923
+ // ---------------------------------------------------------------------------
924
+
925
+ describe("schema", () => {
926
+ function tableNames(): string[] {
927
+ const rows = db.prepare(
928
+ "SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name"
929
+ ).all() as { name: string }[];
930
+ return rows.map((r) => r.name);
931
+ }
932
+
933
+ function triggerNames(): string[] {
934
+ const rows = db.prepare(
935
+ "SELECT name FROM sqlite_master WHERE type = 'trigger' ORDER BY name"
936
+ ).all() as { name: string }[];
937
+ return rows.map((r) => r.name);
938
+ }
939
+
940
+ test("all core tables exist", () => {
941
+ const names = tableNames();
942
+ const expected = [
943
+ "pages", "properties", "callouts", "sections",
944
+ "commands", "command_versions", "ros_versions",
945
+ "devices", "changelogs", "schema_migrations",
946
+ ];
947
+ for (const table of expected) {
948
+ expect(names).toContain(table);
949
+ }
950
+ });
951
+
952
+ test("all FTS5 virtual tables exist", () => {
953
+ const names = tableNames();
954
+ const expected = ["pages_fts", "properties_fts", "callouts_fts", "devices_fts", "changelogs_fts"];
955
+ for (const fts of expected) {
956
+ expect(names).toContain(fts);
957
+ }
958
+ });
959
+
960
+ test("content-sync triggers exist for pages", () => {
961
+ const triggers = triggerNames();
962
+ expect(triggers).toContain("pages_ai");
963
+ expect(triggers).toContain("pages_ad");
964
+ expect(triggers).toContain("pages_au");
965
+ });
966
+
967
+ test("content-sync triggers exist for properties", () => {
968
+ const triggers = triggerNames();
969
+ expect(triggers).toContain("props_ai");
970
+ expect(triggers).toContain("props_ad");
971
+ expect(triggers).toContain("props_au");
972
+ });
973
+
974
+ test("content-sync triggers exist for callouts", () => {
975
+ const triggers = triggerNames();
976
+ expect(triggers).toContain("callouts_ai");
977
+ expect(triggers).toContain("callouts_ad");
978
+ expect(triggers).toContain("callouts_au");
979
+ });
980
+
981
+ test("content-sync triggers exist for devices", () => {
982
+ const triggers = triggerNames();
983
+ expect(triggers).toContain("devices_ai");
984
+ expect(triggers).toContain("devices_ad");
985
+ expect(triggers).toContain("devices_au");
986
+ });
987
+
988
+ test("content-sync triggers exist for changelogs", () => {
989
+ const triggers = triggerNames();
990
+ expect(triggers).toContain("changelogs_ai");
991
+ expect(triggers).toContain("changelogs_ad");
992
+ expect(triggers).toContain("changelogs_au");
993
+ });
994
+ });