@tikoci/rosetta 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/query.test.ts CHANGED
@@ -13,10 +13,12 @@ import { beforeAll, describe, expect, test } from "bun:test";
13
13
  process.env.DB_PATH = ":memory:";
14
14
 
15
15
  // Dynamic imports so the env-var assignment above is visible to db.ts
16
- const { db, initDb } = await import("./db.ts");
16
+ const { db, initDb, getDbStats, checkSchemaVersion, SCHEMA_VERSION } = await import("./db.ts");
17
17
  const {
18
18
  extractTerms,
19
19
  buildFtsQuery,
20
+ exportDevicesCsv,
21
+ exportDeviceTestsCsv,
20
22
  searchPages,
21
23
  getPage,
22
24
  lookupProperty,
@@ -24,7 +26,11 @@ const {
24
26
  searchCallouts,
25
27
  searchChangelogs,
26
28
  checkCommandVersions,
29
+ diffCommandVersions,
27
30
  searchDevices,
31
+ searchDeviceTests,
32
+ getTestResultMeta,
33
+ normalizeDeviceQuery,
28
34
  } = await import("./query.ts");
29
35
  const { parseChangelog } = await import("./extract-changelogs.ts");
30
36
 
@@ -69,6 +75,10 @@ beforeAll(() => {
69
75
 
70
76
  db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
71
77
  VALUES ('7.22', 'stable', 0, '2024-01-01T00:00:00Z')`);
78
+ db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
79
+ VALUES ('7.9', 'stable', 0, '2023-01-01T00:00:00Z')`);
80
+ db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
81
+ VALUES ('7.10.2', 'stable', 0, '2023-06-01T00:00:00Z')`);
72
82
 
73
83
  db.run(`INSERT INTO commands
74
84
  (id, path, name, type, parent_path, page_id, description, ros_version)
@@ -80,6 +90,19 @@ beforeAll(() => {
80
90
 
81
91
  db.run(`INSERT INTO command_versions (command_path, ros_version)
82
92
  VALUES ('/ip/dhcp-server', '7.22')`);
93
+ db.run(`INSERT INTO command_versions (command_path, ros_version)
94
+ VALUES ('/ip/dhcp-server', '7.9')`);
95
+
96
+ // Extra command_versions entries to support diffCommandVersions tests
97
+ // /ip/dhcp-server/lease only in 7.22 (added)
98
+ db.run(`INSERT INTO command_versions (command_path, ros_version)
99
+ VALUES ('/ip/dhcp-server/lease', '7.22')`);
100
+ // /ip/old-feature only in 7.9 (removed by 7.22)
101
+ db.run(`INSERT INTO command_versions (command_path, ros_version)
102
+ VALUES ('/ip/old-feature', '7.9')`);
103
+ // /other/path only in 7.10.2 (outside /ip prefix for prefix-filter test)
104
+ db.run(`INSERT INTO command_versions (command_path, ros_version)
105
+ VALUES ('/other/path', '7.10.2')`);
83
106
 
84
107
  // Device fixtures for searchDevices tests
85
108
  db.run(`INSERT INTO devices
@@ -163,6 +186,75 @@ beforeAll(() => {
163
186
  NULL, 13, NULL, NULL, NULL,
164
187
  NULL, NULL, NULL, 369.00)`);
165
188
 
189
+ // Device fixtures: Unicode superscript names (matching production DB naming)
190
+ db.run(`INSERT INTO devices
191
+ (product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
192
+ license_level, operating_system, ram, ram_mb, storage, storage_mb,
193
+ poe_in, poe_out, wireless_24_chains, wireless_5_chains,
194
+ eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
195
+ eth_multigig, usb_ports, sim_slots, msrp_usd,
196
+ product_url)
197
+ VALUES
198
+ ('hAP ax\u00b2', 'C52iG-5HaxD2HaxD-TC', 'ARM 64bit', 'IPQ-6010', 4, 'auto (864 - 1800) MHz',
199
+ 4, 'RouterOS v7', '1 GB', 1024, '128 MB', 128,
200
+ '802.3af/at', NULL, 2, 2,
201
+ NULL, 4, 1, NULL, NULL,
202
+ NULL, 1, NULL, 119.00,
203
+ 'https://mikrotik.com/product/hap_ax2')`);
204
+
205
+ db.run(`INSERT INTO devices
206
+ (product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
207
+ license_level, operating_system, ram, ram_mb, storage, storage_mb,
208
+ poe_in, poe_out, wireless_24_chains, wireless_5_chains,
209
+ eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
210
+ eth_multigig, usb_ports, sim_slots, msrp_usd)
211
+ VALUES
212
+ ('hAP ac\u00b3', 'RBD53iG-5HacD2HnD', 'ARM 64bit', 'IPQ-4019', 4, '716 MHz',
213
+ 4, 'RouterOS v7', '256 MB', 256, '128 MB', 128,
214
+ NULL, NULL, 2, 2,
215
+ NULL, 5, NULL, NULL, NULL,
216
+ NULL, 1, NULL, 69.00)`);
217
+
218
+ // Device fixtures: RB5009 family for disambiguation testing
219
+ db.run(`INSERT INTO devices
220
+ (product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
221
+ license_level, operating_system, ram, ram_mb, storage, storage_mb,
222
+ poe_in, poe_out, wireless_24_chains, wireless_5_chains,
223
+ eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
224
+ eth_multigig, usb_ports, sim_slots, msrp_usd)
225
+ VALUES
226
+ ('RB5009UG+S+IN', 'RB5009UG+S+IN', 'ARM 64bit', 'Marvell 88F7040', 4, '1400 MHz',
227
+ 5, 'RouterOS v7', '1 GB', 1024, '1 GB', 1024,
228
+ NULL, NULL, NULL, NULL,
229
+ NULL, 7, 1, NULL, 1,
230
+ NULL, 1, NULL, 219.00)`);
231
+
232
+ db.run(`INSERT INTO devices
233
+ (product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
234
+ license_level, operating_system, ram, ram_mb, storage, storage_mb,
235
+ poe_in, poe_out, wireless_24_chains, wireless_5_chains,
236
+ eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
237
+ eth_multigig, usb_ports, sim_slots, msrp_usd)
238
+ VALUES
239
+ ('RB5009UPr+S+IN', 'RB5009UPr+S+IN', 'ARM 64bit', 'Marvell 88F7040', 4, '1400 MHz',
240
+ 5, 'RouterOS v7', '1 GB', 1024, '1 GB', 1024,
241
+ '802.3af/at', '802.3af/at', NULL, NULL,
242
+ NULL, 7, 1, NULL, 1,
243
+ NULL, 1, NULL, 269.00)`);
244
+
245
+ db.run(`INSERT INTO devices
246
+ (product_name, product_code, architecture, cpu, cpu_cores, cpu_frequency,
247
+ license_level, operating_system, ram, ram_mb, storage, storage_mb,
248
+ poe_in, poe_out, wireless_24_chains, wireless_5_chains,
249
+ eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
250
+ eth_multigig, usb_ports, sim_slots, msrp_usd)
251
+ VALUES
252
+ ('RB5009UPr+S+OUT', 'RB5009UPr+S+OUT', 'ARM 64bit', 'Marvell 88F7040', 4, '1400 MHz',
253
+ 5, 'RouterOS v7', '1 GB', 1024, '1 GB', 1024,
254
+ '802.3af/at', '802.3af/at', NULL, NULL,
255
+ NULL, 7, 1, NULL, 1,
256
+ NULL, 1, NULL, 299.00)`);
257
+
166
258
  // Device test results fixtures (hAP ax3 = id 1)
167
259
  db.run(`INSERT INTO device_test_results
168
260
  (device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
@@ -635,8 +727,8 @@ describe("searchCallouts", () => {
635
727
  describe("checkCommandVersions", () => {
636
728
  test("returns versions for known command path", () => {
637
729
  const res = checkCommandVersions("/ip/dhcp-server");
638
- expect(res.versions).toEqual(["7.22"]);
639
- expect(res.first_seen).toBe("7.22");
730
+ expect(res.versions).toEqual(["7.9", "7.22"]);
731
+ expect(res.first_seen).toBe("7.9");
640
732
  expect(res.last_seen).toBe("7.22");
641
733
  });
642
734
 
@@ -660,6 +752,79 @@ describe("checkCommandVersions", () => {
660
752
  });
661
753
  });
662
754
 
755
+ // ---------------------------------------------------------------------------
756
+ // DB integration: diffCommandVersions
757
+ // ---------------------------------------------------------------------------
758
+
759
+ describe("diffCommandVersions", () => {
760
+ test("detects added command paths", () => {
761
+ // /ip/dhcp-server/lease only in 7.22
762
+ const res = diffCommandVersions("7.9", "7.22");
763
+ expect(res.added).toContain("/ip/dhcp-server/lease");
764
+ });
765
+
766
+ test("detects removed command paths", () => {
767
+ // /ip/old-feature only in 7.9
768
+ const res = diffCommandVersions("7.9", "7.22");
769
+ expect(res.removed).toContain("/ip/old-feature");
770
+ });
771
+
772
+ test("does not report unchanged commands as added or removed", () => {
773
+ const res = diffCommandVersions("7.9", "7.22");
774
+ // /ip/dhcp-server is in both versions — should not appear in either list
775
+ expect(res.added).not.toContain("/ip/dhcp-server");
776
+ expect(res.removed).not.toContain("/ip/dhcp-server");
777
+ });
778
+
779
+ test("returns correct counts", () => {
780
+ const res = diffCommandVersions("7.9", "7.22");
781
+ expect(res.added_count).toBe(res.added.length);
782
+ expect(res.removed_count).toBe(res.removed.length);
783
+ });
784
+
785
+ test("path_prefix scopes the diff to a subtree", () => {
786
+ const res = diffCommandVersions("7.9", "7.22", "/ip");
787
+ // /other/path is outside /ip prefix — should not appear
788
+ expect(res.added).not.toContain("/other/path");
789
+ expect(res.removed).not.toContain("/other/path");
790
+ // Results should still include /ip subtree changes
791
+ expect(res.removed).toContain("/ip/old-feature");
792
+ });
793
+
794
+ test("returns from_version and to_version in result", () => {
795
+ const res = diffCommandVersions("7.9", "7.22");
796
+ expect(res.from_version).toBe("7.9");
797
+ expect(res.to_version).toBe("7.22");
798
+ });
799
+
800
+ test("returns path_prefix in result", () => {
801
+ const res = diffCommandVersions("7.9", "7.22", "/ip/firewall");
802
+ expect(res.path_prefix).toBe("/ip/firewall");
803
+ });
804
+
805
+ test("path_prefix null when not provided", () => {
806
+ const res = diffCommandVersions("7.9", "7.22");
807
+ expect(res.path_prefix).toBeNull();
808
+ });
809
+
810
+ test("adds note for untracked from_version", () => {
811
+ const res = diffCommandVersions("7.1", "7.22");
812
+ expect(res.note).toContain("7.1");
813
+ expect(res.note).toContain("not in the tracked range");
814
+ });
815
+
816
+ test("adds note for untracked to_version", () => {
817
+ const res = diffCommandVersions("7.9", "7.99");
818
+ expect(res.note).toContain("7.99");
819
+ });
820
+
821
+ test("returns empty diff for same version", () => {
822
+ const res = diffCommandVersions("7.22", "7.22");
823
+ expect(res.added).toHaveLength(0);
824
+ expect(res.removed).toHaveLength(0);
825
+ });
826
+ });
827
+
663
828
  // ---------------------------------------------------------------------------
664
829
  // DB integration: searchDevices
665
830
  // ---------------------------------------------------------------------------
@@ -701,14 +866,14 @@ describe("searchDevices", () => {
701
866
  test("filter by architecture", () => {
702
867
  const res = searchDevices("", { architecture: "ARM 64bit" });
703
868
  expect(res.mode).toBe("filter");
704
- expect(res.results.length).toBe(3);
869
+ expect(res.results.length).toBe(8); // hAP ax3 + CCR2216 + Chateau + hAP ax² + hAP ac³ + 3× RB5009
705
870
  expect(res.results.every((d) => d.architecture === "ARM 64bit")).toBe(true);
706
871
  });
707
872
 
708
873
  test("filter by min_ram_mb", () => {
709
874
  const res = searchDevices("", { min_ram_mb: 1024 });
710
875
  expect(res.mode).toBe("filter");
711
- expect(res.results.length).toBe(5); // hAP ax3 (1024) + CCR2216 (16384) + Chateau (1024) + RB1100AHx4 (1024) + RB1100AHx4 Dude (1024)
876
+ expect(res.results.length).toBe(9); // +hAP ax²(1024) +3×RB5009(1024) not hAP ac³(256)
712
877
  expect(res.results.every((d) => (d.ram_mb ?? 0) >= 1024)).toBe(true);
713
878
  });
714
879
 
@@ -721,19 +886,19 @@ describe("searchDevices", () => {
721
886
 
722
887
  test("filter by has_poe", () => {
723
888
  const res = searchDevices("", { has_poe: true });
724
- expect(res.results).toHaveLength(1);
725
- expect(res.results[0].product_name).toBe("hAP ax3");
889
+ expect(res.results).toHaveLength(4); // hAP ax3 + hAP ax² + RB5009UPr IN + RB5009UPr OUT
890
+ expect(res.results.every((d) => d.poe_in != null || d.poe_out != null)).toBe(true);
726
891
  });
727
892
 
728
893
  test("filter by has_wireless", () => {
729
894
  const res = searchDevices("", { has_wireless: true });
730
- expect(res.results).toHaveLength(3); // hAP ax3 + hAP lite + Chateau LTE18
895
+ expect(res.results).toHaveLength(5); // hAP ax3 + hAP lite + Chateau LTE18 + hAP ax² + hAP ac³
731
896
  });
732
897
 
733
898
  test("filter by min_storage_mb", () => {
734
899
  const res = searchDevices("", { min_storage_mb: 128 });
735
900
  expect(res.mode).toBe("filter");
736
- expect(res.results.length).toBe(5); // hAP ax3 (128) + CCR2216 (128) + Chateau (128) + RB1100AHx4 (128) + RB1100AHx4 Dude (512)
901
+ expect(res.results.length).toBe(10); // +hAP ax²(128) +hAP ac³(128) +3×RB5009(1024)
737
902
  expect(res.results.every((d) => (d.storage_mb ?? 0) >= 128)).toBe(true);
738
903
  });
739
904
 
@@ -802,10 +967,11 @@ describe("searchDevices", () => {
802
967
  expect(res.mode).toBe("exact");
803
968
  expect(res.results).toHaveLength(1);
804
969
  const dev = res.results[0];
805
- expect(dev.test_results).toBeDefined();
806
- expect(dev.test_results!.length).toBe(3);
807
- expect(dev.test_results!.some((t) => t.test_type === "ethernet")).toBe(true);
808
- expect(dev.test_results!.some((t) => t.test_type === "ipsec")).toBe(true);
970
+ const testResults = dev.test_results;
971
+ expect(testResults).toBeDefined();
972
+ expect(testResults?.length).toBe(3);
973
+ expect(testResults?.some((t) => t.test_type === "ethernet")).toBe(true);
974
+ expect(testResults?.some((t) => t.test_type === "ipsec")).toBe(true);
809
975
  });
810
976
 
811
977
  test("LIKE match with ≤5 results includes test_results", () => {
@@ -827,6 +993,233 @@ describe("searchDevices", () => {
827
993
  expect(res.results[0].product_url).toBeNull();
828
994
  expect(res.results[0].block_diagram_url).toBeNull();
829
995
  });
996
+
997
+ test("has_more is false when all results fit", () => {
998
+ const res = searchDevices("", { architecture: "ARM 64bit" }, 50);
999
+ expect(res.has_more).toBe(false);
1000
+ });
1001
+
1002
+ test("has_more is true when results are truncated", () => {
1003
+ // We have multiple devices; limit to 1 with a broad filter
1004
+ const res = searchDevices("", { architecture: "ARM 64bit" }, 1);
1005
+ expect(res.results).toHaveLength(1);
1006
+ expect(res.has_more).toBe(true);
1007
+ });
1008
+
1009
+ test("single FTS match attaches test_results", () => {
1010
+ // "hAP ax3" as FTS should find exactly one match and attach test results
1011
+ const res = searchDevices("hAP ax3");
1012
+ if (res.mode === "exact" || res.results.length === 1) {
1013
+ expect(res.results[0].test_results).toBeDefined();
1014
+ }
1015
+ });
1016
+
1017
+ test("LIKE splits on dashes so rb1100-ahx4 finds RB1100AHx4 family via LIKE", () => {
1018
+ // Users may type model numbers with dashes as word separators
1019
+ const res = searchDevices("RB1100-AHx4");
1020
+ expect(res.mode).toBe("like");
1021
+ expect(res.results.length).toBeGreaterThanOrEqual(1);
1022
+ expect(res.results.every((d) => d.product_name.includes("RB1100"))).toBe(true);
1023
+ });
1024
+
1025
+ test("slug-normalized LIKE finds hapax3 → hAP ax3 via product_url", () => {
1026
+ // Concatenated slug-style query: spaces dropped, ASCII digit for superscript.
1027
+ // Falls through regular LIKE (no match: 'hapax3' not a substring of 'hAP ax3')
1028
+ // then slug-normalized path matches product_url /product/hap_ax3 → hap_ax3 stripped.
1029
+ const res = searchDevices("hapax3");
1030
+ expect(res.results).toHaveLength(1);
1031
+ expect(res.results[0].product_name).toBe("hAP ax3"); // fixture uses ASCII 3
1032
+ });
1033
+
1034
+ test("dash-split LIKE finds hap-ax3 → hAP ax3", () => {
1035
+ // Dash as separator: split → ['hap','ax3'] → LIKE '%hap%' AND '%ax3%'
1036
+ const res = searchDevices("hap-ax3");
1037
+ expect(res.mode).toBe("like");
1038
+ expect(res.results).toHaveLength(1);
1039
+ expect(res.results[0].product_name).toBe("hAP ax3");
1040
+ });
1041
+
1042
+ test("underscore-split LIKE finds hap_ax3 → hAP ax3", () => {
1043
+ // Underscore-separated slug form
1044
+ const res = searchDevices("hap_ax3");
1045
+ expect(res.mode).toBe("like");
1046
+ expect(res.results).toHaveLength(1);
1047
+ expect(res.results[0].product_name).toBe("hAP ax3");
1048
+ });
1049
+
1050
+ // ── Unicode superscript normalization ──
1051
+
1052
+ test("normalizeDeviceQuery converts superscripts to ASCII", () => {
1053
+ expect(normalizeDeviceQuery("hAP ax³")).toBe("hAP ax3");
1054
+ expect(normalizeDeviceQuery("hAP ax²")).toBe("hAP ax2");
1055
+ expect(normalizeDeviceQuery("hAP ac³")).toBe("hAP ac3");
1056
+ expect(normalizeDeviceQuery("no superscripts")).toBe("no superscripts");
1057
+ });
1058
+
1059
+ test("exact match with Unicode query hAP ax³ finds ASCII-named hAP ax3", () => {
1060
+ // User pastes Unicode name, DB has ASCII variant
1061
+ const res = searchDevices("hAP ax\u00B3");
1062
+ expect(res.mode).toBe("exact");
1063
+ expect(res.results).toHaveLength(1);
1064
+ expect(res.results[0].product_name).toBe("hAP ax3");
1065
+ });
1066
+
1067
+ test("ASCII query hap ax2 finds Unicode-named hAP ax²", () => {
1068
+ // User types ASCII digits, DB has Unicode superscript
1069
+ const res = searchDevices("hap ax2");
1070
+ expect(res.mode).toBe("exact");
1071
+ expect(res.results).toHaveLength(1);
1072
+ expect(res.results[0].product_name).toBe("hAP ax\u00B2");
1073
+ });
1074
+
1075
+ test("ASCII query hap ac3 finds Unicode-named hAP ac³", () => {
1076
+ const res = searchDevices("hap ac3");
1077
+ expect(res.mode).toBe("exact");
1078
+ expect(res.results).toHaveLength(1);
1079
+ expect(res.results[0].product_name).toBe("hAP ac\u00B3");
1080
+ });
1081
+
1082
+ test("single-digit term preserved: hap ax 3 finds hAP ax3 (not 4 results)", () => {
1083
+ // Previously: digit '3' was filtered by length >= 2, leaving just 'hap' + 'ax'
1084
+ // which matched hAP ax S, hAP ax lite, hAP ax², hAP ax³ (broad).
1085
+ // Now: single digits kept when accompanied by longer terms.
1086
+ const res = searchDevices("hap ax 3");
1087
+ expect(res.results).toHaveLength(1);
1088
+ expect(res.results[0].product_name).toBe("hAP ax3");
1089
+ });
1090
+
1091
+ // ── Multi-match disambiguation ──
1092
+
1093
+ test("RB5009 family query returns all 3 variants with disambiguation note", () => {
1094
+ const res = searchDevices("RB5009");
1095
+ expect(res.mode).toBe("like");
1096
+ expect(res.results).toHaveLength(3);
1097
+ expect(res.results.every((d) => d.product_name.includes("RB5009"))).toBe(true);
1098
+ // Should include disambiguation note for multi-match
1099
+ expect(res.note).toBeDefined();
1100
+ expect(res.note).toContain("3 devices");
1101
+ });
1102
+
1103
+ test("disambiguation note mentions PoE difference for RB5009 family", () => {
1104
+ const res = searchDevices("RB5009");
1105
+ expect(res.note).toBeDefined();
1106
+ // RB5009UG has no PoE, RB5009UPr has PoE → note should mention it
1107
+ expect(res.note).toContain("PoE");
1108
+ });
1109
+
1110
+ test("disambiguation note mentions enclosure difference for RB5009 family", () => {
1111
+ const res = searchDevices("RB5009");
1112
+ expect(res.note).toBeDefined();
1113
+ // IN vs OUT enclosures
1114
+ expect(res.note).toContain("enclosure");
1115
+ });
1116
+
1117
+ test("single LIKE match has no disambiguation note", () => {
1118
+ const res = searchDevices("CCR2216");
1119
+ expect(res.results).toHaveLength(1);
1120
+ expect(res.note).toBeUndefined();
1121
+ });
1122
+ });
1123
+
1124
+ // ---------------------------------------------------------------------------
1125
+ // searchDeviceTests: cross-device test queries
1126
+ // ---------------------------------------------------------------------------
1127
+
1128
+ describe("searchDeviceTests", () => {
1129
+ test("returns all test results with no filters", () => {
1130
+ const res = searchDeviceTests({});
1131
+ expect(res.results.length).toBe(3);
1132
+ expect(res.total).toBe(3);
1133
+ });
1134
+
1135
+ test("filters by test_type", () => {
1136
+ const res = searchDeviceTests({ test_type: "ethernet" });
1137
+ expect(res.results.every((r) => r.test_type === "ethernet")).toBe(true);
1138
+ expect(res.results.length).toBe(2);
1139
+ });
1140
+
1141
+ test("filters by test_type and mode", () => {
1142
+ const res = searchDeviceTests({ test_type: "ipsec", mode: "Single tunnel" });
1143
+ expect(res.results).toHaveLength(1);
1144
+ expect(res.results[0].configuration).toBe("AES-128-CBC + SHA1");
1145
+ });
1146
+
1147
+ test("configuration uses LIKE matching", () => {
1148
+ const res = searchDeviceTests({ configuration: "25 ip filter" });
1149
+ expect(res.results).toHaveLength(1);
1150
+ expect(res.results[0].configuration).toBe("25 ip filter rules");
1151
+ });
1152
+
1153
+ test("filters by packet_size", () => {
1154
+ const res = searchDeviceTests({ packet_size: 512 });
1155
+ expect(res.results.every((r) => r.packet_size === 512)).toBe(true);
1156
+ });
1157
+
1158
+ test("sorts by mbps descending by default", () => {
1159
+ const res = searchDeviceTests({ test_type: "ethernet" });
1160
+ if (res.results.length >= 2) {
1161
+ const mbps = res.results.map((r) => r.throughput_mbps ?? 0);
1162
+ expect(mbps[0]).toBeGreaterThanOrEqual(mbps[1]);
1163
+ }
1164
+ });
1165
+
1166
+ test("result shape has expected fields and no cpu fields", () => {
1167
+ const res = searchDeviceTests({ test_type: "ethernet" });
1168
+ const row = res.results[0];
1169
+ // Present: device identity + test data
1170
+ expect(row.product_name).toBeDefined();
1171
+ expect(row.product_code).toBeDefined();
1172
+ expect(row.architecture).toBeDefined();
1173
+ expect(row.test_type).toBeDefined();
1174
+ expect(row.mode).toBeDefined();
1175
+ expect(row.configuration).toBeDefined();
1176
+ expect(row.packet_size).toBeDefined();
1177
+ expect(row.throughput_mbps).toBeDefined();
1178
+ // Absent: cpu details (available via device_lookup)
1179
+ expect("cpu" in row).toBe(false);
1180
+ expect("cpu_cores" in row).toBe(false);
1181
+ expect("cpu_frequency" in row).toBe(false);
1182
+ });
1183
+
1184
+ test("respects limit", () => {
1185
+ const res = searchDeviceTests({}, 1);
1186
+ expect(res.results).toHaveLength(1);
1187
+ expect(res.total).toBe(3);
1188
+ });
1189
+ });
1190
+
1191
+ describe("dataset CSV exports", () => {
1192
+ test("exports full device test results as CSV", () => {
1193
+ const csv = exportDeviceTestsCsv();
1194
+ const lines = csv.trim().split("\n");
1195
+
1196
+ expect(lines[0]).toBe("product_name,product_code,architecture,cpu,cpu_cores,cpu_frequency,test_type,mode,configuration,packet_size,throughput_kpps,throughput_mbps,product_url");
1197
+ expect(lines).toHaveLength(4);
1198
+ expect(csv).toContain("hAP ax3");
1199
+ expect(csv).toContain("IPQ-6010");
1200
+ expect(csv).toContain("https://mikrotik.com/product/hap_ax3");
1201
+ });
1202
+
1203
+ test("exports full device catalog as CSV", () => {
1204
+ const csv = exportDevicesCsv();
1205
+ const lines = csv.trim().split("\n");
1206
+
1207
+ expect(lines[0]).toBe("product_name,product_code,architecture,cpu,cpu_cores,cpu_frequency,license_level,operating_system,ram,ram_mb,storage,storage_mb,dimensions,poe_in,poe_out,max_power_w,wireless_24_chains,wireless_5_chains,eth_fast,eth_gigabit,eth_2500,sfp_ports,sfp_plus_ports,eth_multigig,usb_ports,sim_slots,msrp_usd,product_url,block_diagram_url");
1208
+ expect(lines).toHaveLength(12); // header + 11 devices (6 original + 5 new fixtures)
1209
+ expect(csv).toContain("CCR2216-1G-12XS-2XQ");
1210
+ expect(csv).toContain("https://cdn.mikrotik.com/web-assets/product_files/hap_ax3_123.png");
1211
+ expect(lines[0].startsWith("id,")).toBe(false);
1212
+ });
1213
+ });
1214
+
1215
+ describe("getTestResultMeta", () => {
1216
+ test("returns distinct values", () => {
1217
+ const meta = getTestResultMeta();
1218
+ expect(meta.test_types).toContain("ethernet");
1219
+ expect(meta.test_types).toContain("ipsec");
1220
+ expect(meta.modes).toContain("Routing");
1221
+ expect(meta.packet_sizes).toContain(512);
1222
+ });
830
1223
  });
831
1224
 
832
1225
  // ---------------------------------------------------------------------------
@@ -1036,4 +1429,24 @@ describe("schema", () => {
1036
1429
  expect(triggers).toContain("changelogs_ad");
1037
1430
  expect(triggers).toContain("changelogs_au");
1038
1431
  });
1432
+
1433
+ test("PRAGMA user_version matches SCHEMA_VERSION", () => {
1434
+ const result = checkSchemaVersion();
1435
+ expect(result.ok).toBe(true);
1436
+ expect(result.actual).toBe(SCHEMA_VERSION);
1437
+ expect(result.expected).toBe(SCHEMA_VERSION);
1438
+ });
1439
+ });
1440
+
1441
+ // ---------------------------------------------------------------------------
1442
+ // getDbStats: version range uses semantic sort (not lexicographic)
1443
+ // ---------------------------------------------------------------------------
1444
+
1445
+ describe("getDbStats", () => {
1446
+ test("version range is semantically sorted (7.9 < 7.10.2 < 7.22)", () => {
1447
+ const stats = getDbStats();
1448
+ // Fixtures have 7.9, 7.10.2, 7.22 — lexicographic MIN would give "7.10.2", not "7.9"
1449
+ expect(stats.ros_version_min).toBe("7.9");
1450
+ expect(stats.ros_version_max).toBe("7.22");
1451
+ });
1039
1452
  });