@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.
- package/LICENSE +21 -0
- package/README.md +333 -0
- package/bin/rosetta.js +34 -0
- package/matrix/2026-03-25/matrix.csv +145 -0
- package/matrix/CLAUDE.md +7 -0
- package/matrix/get-mikrotik-products-csv.sh +20 -0
- package/package.json +34 -0
- package/src/assess-html.ts +267 -0
- package/src/db.ts +360 -0
- package/src/extract-all-versions.ts +147 -0
- package/src/extract-changelogs.ts +266 -0
- package/src/extract-commands.ts +175 -0
- package/src/extract-devices.ts +194 -0
- package/src/extract-html.ts +379 -0
- package/src/extract-properties.ts +234 -0
- package/src/link-commands.ts +208 -0
- package/src/mcp.ts +725 -0
- package/src/query.test.ts +994 -0
- package/src/query.ts +990 -0
- package/src/release.test.ts +280 -0
- package/src/restraml.ts +65 -0
- package/src/search.ts +49 -0
- package/src/setup.ts +224 -0
|
@@ -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
|
+
});
|