@tikoci/rosetta 0.6.7 → 0.6.8

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,373 @@
1
+ /**
2
+ * schema-roundtrip.test.ts — Tests for extract-schema.ts importer.
3
+ *
4
+ * Tests cover:
5
+ * - Fixture import into in-memory DB
6
+ * - Arch-specific node detection (x86-only, arm64-only, shared)
7
+ * - dir_role derivation (list, namespace, hybrid)
8
+ * - desc_raw parsing (string, enum, time, range, script)
9
+ * - _completion data landing in _attrs
10
+ * - schema_node_presence row counts
11
+ * - Legacy commands table regeneration
12
+ * - parseDesc() unit tests
13
+ */
14
+
15
+ import { Database } from "bun:sqlite";
16
+ import { beforeAll, describe, expect, test } from "bun:test";
17
+ import type { FlatNode } from "./extract-schema.ts";
18
+ import { importSchemaNodes, mergeArchNodes, parseDesc, walk } from "./extract-schema.ts";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // In-memory DB with schema_nodes tables — mirrors initDb() schema
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function createTestDb(): Database {
25
+ const db = new Database(":memory:");
26
+ db.run("PRAGMA journal_mode=WAL;");
27
+ db.run("PRAGMA foreign_keys=ON;");
28
+
29
+ // Minimal pages table for FK
30
+ db.run(`CREATE TABLE pages (id INTEGER PRIMARY KEY, title TEXT);`);
31
+
32
+ // schema_nodes
33
+ db.run(`CREATE TABLE schema_nodes (
34
+ id INTEGER PRIMARY KEY,
35
+ path TEXT NOT NULL,
36
+ name TEXT NOT NULL,
37
+ type TEXT NOT NULL,
38
+ parent_id INTEGER REFERENCES schema_nodes(id),
39
+ parent_path TEXT,
40
+ dir_role TEXT,
41
+ desc_raw TEXT,
42
+ data_type TEXT,
43
+ enum_values TEXT,
44
+ enum_multi INTEGER,
45
+ type_tag TEXT,
46
+ range_min TEXT,
47
+ range_max TEXT,
48
+ max_length INTEGER,
49
+ _arch TEXT,
50
+ _package TEXT,
51
+ _attrs TEXT,
52
+ page_id INTEGER REFERENCES pages(id),
53
+ UNIQUE(path, type)
54
+ );`);
55
+
56
+ db.run(`CREATE INDEX idx_sn_parent ON schema_nodes(parent_path);`);
57
+ db.run(`CREATE INDEX idx_sn_type ON schema_nodes(type);`);
58
+ db.run(`CREATE INDEX idx_sn_path ON schema_nodes(path);`);
59
+
60
+ db.run(`CREATE TABLE schema_node_presence (
61
+ node_id INTEGER NOT NULL REFERENCES schema_nodes(id),
62
+ version TEXT NOT NULL,
63
+ PRIMARY KEY (node_id, version)
64
+ );`);
65
+
66
+ db.run(`CREATE INDEX idx_snp_version ON schema_node_presence(version);`);
67
+
68
+ // Legacy compat tables
69
+ db.run(`CREATE TABLE commands (
70
+ id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE,
71
+ name TEXT NOT NULL, type TEXT NOT NULL,
72
+ parent_path TEXT, page_id INTEGER,
73
+ description TEXT, ros_version TEXT
74
+ );`);
75
+
76
+ db.run(`CREATE TABLE command_versions (
77
+ command_path TEXT NOT NULL, ros_version TEXT NOT NULL,
78
+ PRIMARY KEY (command_path, ros_version)
79
+ );`);
80
+
81
+ db.run(`CREATE TABLE ros_versions (
82
+ version TEXT NOT NULL, arch TEXT NOT NULL DEFAULT 'x86',
83
+ channel TEXT, extra_packages INTEGER NOT NULL DEFAULT 0,
84
+ extracted_at TEXT NOT NULL, generated_at TEXT,
85
+ crash_paths_tested TEXT, crash_paths_crashed TEXT,
86
+ crash_paths_safe TEXT, completion_stats TEXT,
87
+ source_url TEXT, api_transport TEXT,
88
+ enrichment_duration_ms INTEGER, _attrs TEXT,
89
+ PRIMARY KEY (version, arch)
90
+ );`);
91
+
92
+ return db;
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Load fixtures
97
+ // ---------------------------------------------------------------------------
98
+
99
+ let x86Data: Record<string, unknown>;
100
+ let arm64Data: Record<string, unknown>;
101
+
102
+ beforeAll(async () => {
103
+ x86Data = await Bun.file("fixtures/deep-inspect.x86.sample.json").json();
104
+ arm64Data = await Bun.file("fixtures/deep-inspect.arm64.sample.json").json();
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Tests
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe("parseDesc", () => {
112
+ test("string value", () => {
113
+ const r = parseDesc("string value");
114
+ expect(r.dataType).toBe("string");
115
+ expect(r.maxLength).toBeNull();
116
+ });
117
+
118
+ test("string value, max length N", () => {
119
+ const r = parseDesc("string value, max length 255");
120
+ expect(r.dataType).toBe("string");
121
+ expect(r.maxLength).toBe(255);
122
+ });
123
+
124
+ test("script", () => {
125
+ const r = parseDesc("script");
126
+ expect(r.dataType).toBe("script");
127
+ });
128
+
129
+ test("time interval", () => {
130
+ const r = parseDesc("time interval");
131
+ expect(r.dataType).toBe("time");
132
+ expect(r.rangeMin).toBeNull();
133
+ });
134
+
135
+ test("time interval with range", () => {
136
+ const r = parseDesc("00:00:00.100..00:30:00 (time interval)");
137
+ expect(r.dataType).toBe("time");
138
+ expect(r.rangeMin).toBe("00:00:00.100");
139
+ expect(r.rangeMax).toBe("00:30:00");
140
+ });
141
+
142
+ test("simple enum", () => {
143
+ const r = parseDesc("auto|disabled|enabled");
144
+ expect(r.dataType).toBe("enum");
145
+ expect(r.enumValues).not.toBeNull();
146
+ expect(JSON.parse(r.enumValues as string)).toEqual(["auto", "disabled", "enabled"]);
147
+ expect(r.enumMulti).toBeNull();
148
+ });
149
+
150
+ test("enum with multi marker and type tag", () => {
151
+ const r = parseDesc("ftp|reboot|read|write|policy|test|password|sniff|sensitive|romon[,Permission*]");
152
+ expect(r.dataType).toBe("enum");
153
+ expect(r.enumMulti).toBe(1);
154
+ expect(r.typeTag).toBe("Permission");
155
+ expect(r.enumValues).not.toBeNull();
156
+ const vals = JSON.parse(r.enumValues as string);
157
+ expect(vals).toContain("ftp");
158
+ expect(vals).toContain("romon");
159
+ expect(vals.length).toBe(10);
160
+ });
161
+
162
+ test("null desc returns all nulls", () => {
163
+ const r = parseDesc(null);
164
+ expect(r.dataType).toBeNull();
165
+ });
166
+
167
+ test("unknown desc returns all nulls", () => {
168
+ const r = parseDesc("something custom");
169
+ expect(r.dataType).toBeNull();
170
+ });
171
+
172
+ test("integer range", () => {
173
+ const r = parseDesc("0..4294967295 (integer)");
174
+ expect(r.dataType).toBe("integer");
175
+ expect(r.rangeMin).toBe("0");
176
+ expect(r.rangeMax).toBe("4294967295");
177
+ });
178
+ });
179
+
180
+ describe("fixture import", () => {
181
+ test("walk produces expected node counts", () => {
182
+ const x86Nodes: FlatNode[] = [];
183
+ const arm64Nodes: FlatNode[] = [];
184
+ walk(x86Data, "", x86Nodes);
185
+ walk(arm64Data, "", arm64Nodes);
186
+
187
+ // x86 has /system/check-disk, /system/console, /system/console/screen (3 extra)
188
+ // arm64 has /interface/wifi-qcom, wifi-qcom/info, wifi-qcom/info/interface (3 extra)
189
+ // Both have /app/add and /app/add/copy-from
190
+ expect(x86Nodes.length).toBe(33);
191
+ expect(arm64Nodes.length).toBe(33);
192
+ });
193
+
194
+ test("arch diff detection", () => {
195
+ const x86Nodes: FlatNode[] = [];
196
+ const arm64Nodes: FlatNode[] = [];
197
+ walk(x86Data, "", x86Nodes);
198
+ walk(arm64Data, "", arm64Nodes);
199
+
200
+ const merged = mergeArchNodes(x86Nodes, arm64Nodes);
201
+
202
+ const x86Only = merged.filter((n) => n.arch === "x86");
203
+ const arm64Only = merged.filter((n) => n.arch === "arm64");
204
+ const shared = merged.filter((n) => n.arch === null);
205
+
206
+ expect(x86Only.length).toBe(3);
207
+ expect(arm64Only.length).toBe(3);
208
+ expect(shared.length).toBe(30);
209
+ expect(merged.length).toBe(36);
210
+
211
+ // Verify specific arch-only paths
212
+ const x86Paths = x86Only.map((n) => n.path).sort();
213
+ expect(x86Paths).toContain("/system/check-disk");
214
+ expect(x86Paths).toContain("/system/console");
215
+ expect(x86Paths).toContain("/system/console/screen");
216
+
217
+ const arm64Paths = arm64Only.map((n) => n.path).sort();
218
+ expect(arm64Paths).toContain("/interface/wifi-qcom");
219
+ expect(arm64Paths).toContain("/interface/wifi-qcom/info");
220
+ expect(arm64Paths).toContain("/interface/wifi-qcom/info/interface");
221
+ });
222
+
223
+ test("completion data preserved in merge", () => {
224
+ const x86Nodes: FlatNode[] = [];
225
+ const arm64Nodes: FlatNode[] = [];
226
+ walk(x86Data, "", x86Nodes);
227
+ walk(arm64Data, "", arm64Nodes);
228
+
229
+ const merged = mergeArchNodes(x86Nodes, arm64Nodes);
230
+ const withCompletion = merged.filter((n) => n.completion !== null);
231
+ expect(withCompletion.length).toBe(4);
232
+
233
+ // Check disabled arg completion shape
234
+ const disabled = withCompletion.find((n) => n.path === "/ip/address/add/disabled");
235
+ expect(disabled).toBeDefined();
236
+ expect(disabled?.completion).toHaveProperty("no");
237
+ expect(disabled?.completion).toHaveProperty("yes");
238
+ expect(disabled?.completion?.no.style).toBe("arg");
239
+
240
+ // Check copy-from with desc
241
+ const copyFrom = withCompletion.find((n) => n.path === "/app/add/copy-from");
242
+ expect(copyFrom).toBeDefined();
243
+ expect(copyFrom?.completion?.["my-app"].desc).toBe("My custom app");
244
+ });
245
+
246
+ test("full import into in-memory DB via importSchemaNodes", () => {
247
+ const testDb = createTestDb();
248
+ const x86Nodes: FlatNode[] = [];
249
+ const arm64Nodes: FlatNode[] = [];
250
+ walk(x86Data, "", x86Nodes);
251
+ walk(arm64Data, "", arm64Nodes);
252
+ const merged = mergeArchNodes(x86Nodes, arm64Nodes);
253
+
254
+ importSchemaNodes(testDb, merged, "7.99-fixture", {
255
+ accumulate: false,
256
+ extraPackages: false,
257
+ channel: "stable",
258
+ x86Source: "fixtures/deep-inspect.x86.sample.json",
259
+ arm64Source: "fixtures/deep-inspect.arm64.sample.json",
260
+ });
261
+
262
+ // Verify counts
263
+ const count = (sql: string) => (testDb.prepare(sql).get() as { c: number }).c;
264
+ expect(count("SELECT COUNT(*) as c FROM schema_nodes")).toBe(36);
265
+ expect(count("SELECT COUNT(*) as c FROM schema_nodes WHERE _arch IS NULL")).toBe(30);
266
+ expect(count("SELECT COUNT(*) as c FROM schema_nodes WHERE _arch = 'x86'")).toBe(3);
267
+ expect(count("SELECT COUNT(*) as c FROM schema_nodes WHERE _arch = 'arm64'")).toBe(3);
268
+ expect(count("SELECT COUNT(*) as c FROM schema_nodes WHERE _attrs IS NOT NULL")).toBe(4);
269
+
270
+ // Verify dir_role
271
+ const ipAddr = testDb.prepare("SELECT dir_role FROM schema_nodes WHERE path = '/ip/address'").get() as { dir_role: string };
272
+ expect(ipAddr.dir_role).toBe("list"); // has cmds: add, set, remove, print
273
+
274
+ const ip = testDb.prepare("SELECT dir_role FROM schema_nodes WHERE path = '/ip'").get() as { dir_role: string };
275
+ expect(ip.dir_role).toBe("namespace"); // only has dir children
276
+
277
+ const system = testDb.prepare("SELECT dir_role FROM schema_nodes WHERE path = '/system'").get() as { dir_role: string };
278
+ // /system has both dirs (console, script) and cmds (shutdown, check-disk)
279
+ expect(system.dir_role).toBe("hybrid");
280
+
281
+ // Verify desc parsing
282
+ const comment = testDb.prepare("SELECT data_type, max_length FROM schema_nodes WHERE path = '/ip/address/add/comment'").get() as { data_type: string; max_length: number };
283
+ expect(comment.data_type).toBe("string");
284
+ expect(comment.max_length).toBe(255);
285
+
286
+ const policy = testDb.prepare("SELECT data_type, enum_values, enum_multi, type_tag FROM schema_nodes WHERE path = '/system/script/add/policy'").get() as { data_type: string; enum_values: string; enum_multi: number; type_tag: string };
287
+ expect(policy.data_type).toBe("enum");
288
+ expect(policy.enum_multi).toBe(1);
289
+ expect(policy.type_tag).toBe("Permission");
290
+ expect(JSON.parse(policy.enum_values)).toContain("ftp");
291
+
292
+ const interval = testDb.prepare("SELECT data_type, range_min, range_max FROM schema_nodes WHERE path = '/interface/monitor/interval'").get() as { data_type: string; range_min: string; range_max: string };
293
+ expect(interval.data_type).toBe("time");
294
+ expect(interval.range_min).toBe("00:00:00.100");
295
+ expect(interval.range_max).toBe("00:30:00");
296
+
297
+ const source = testDb.prepare("SELECT data_type FROM schema_nodes WHERE path = '/system/script/add/source'").get() as { data_type: string };
298
+ expect(source.data_type).toBe("script");
299
+
300
+ // Verify completion round-trip
301
+ const disabled = testDb.prepare("SELECT _attrs FROM schema_nodes WHERE path = '/ip/address/add/disabled'").get() as { _attrs: string };
302
+ const attrs = JSON.parse(disabled._attrs);
303
+ expect(attrs.completion).toBeDefined();
304
+ expect(attrs.completion.no.style).toBe("arg");
305
+ expect(attrs.completion.yes.preference).toBe(96);
306
+
307
+ const copyFrom = testDb.prepare("SELECT _attrs FROM schema_nodes WHERE path = '/app/add/copy-from'").get() as { _attrs: string };
308
+ const cfAttrs = JSON.parse(copyFrom._attrs);
309
+ expect(cfAttrs.completion["my-app"].desc).toBe("My custom app");
310
+ expect(cfAttrs.completion["dns-doh"].desc).toBe("DNS over HTTPS");
311
+
312
+ // Verify legacy compat: commands table regenerated
313
+ expect(count("SELECT COUNT(*) as c FROM commands")).toBe(36);
314
+
315
+ // Verify schema_node_presence
316
+ expect(count("SELECT COUNT(*) as c FROM schema_node_presence")).toBe(36);
317
+
318
+ // Verify command_versions compat
319
+ expect(count("SELECT COUNT(*) as c FROM command_versions")).toBe(36);
320
+
321
+ // Verify parent_id self-join: dir → dir
322
+ const ipAddrParent = testDb.prepare(`
323
+ SELECT p.path AS parent FROM schema_nodes c
324
+ JOIN schema_nodes p ON p.id = c.parent_id
325
+ WHERE c.path = '/ip/address'
326
+ `).get() as { parent: string } | null;
327
+ expect(ipAddrParent?.parent).toBe("/ip");
328
+
329
+ // Verify parent_id self-join: arg → cmd (the fix — previously type='dir' filter broke this)
330
+ const disabledParent = testDb.prepare(`
331
+ SELECT p.path AS parent, p.type AS parent_type FROM schema_nodes c
332
+ JOIN schema_nodes p ON p.id = c.parent_id
333
+ WHERE c.path = '/ip/address/add/disabled'
334
+ `).get() as { parent: string; parent_type: string } | null;
335
+ expect(disabledParent?.parent).toBe("/ip/address/add");
336
+ expect(disabledParent?.parent_type).toBe("cmd");
337
+
338
+ testDb.close();
339
+ });
340
+ });
341
+
342
+ describe("schema_node_presence", () => {
343
+ test("presence populated for all nodes via importSchemaNodes", () => {
344
+ const testDb = createTestDb();
345
+ const x86Nodes: FlatNode[] = [];
346
+ const arm64Nodes: FlatNode[] = [];
347
+ walk(x86Data, "", x86Nodes);
348
+ walk(arm64Data, "", arm64Nodes);
349
+ const merged = mergeArchNodes(x86Nodes, arm64Nodes);
350
+
351
+ importSchemaNodes(testDb, merged, "7.99-fixture", {
352
+ accumulate: false,
353
+ extraPackages: false,
354
+ channel: "stable",
355
+ x86Source: "fixtures/deep-inspect.x86.sample.json",
356
+ arm64Source: "fixtures/deep-inspect.arm64.sample.json",
357
+ });
358
+
359
+ const count = (testDb.prepare("SELECT COUNT(*) as c FROM schema_node_presence").get() as { c: number }).c;
360
+ expect(count).toBe(36);
361
+
362
+ // Verify specific node presence
363
+ const wifiQcom = testDb.prepare(`
364
+ SELECT snp.version FROM schema_node_presence snp
365
+ JOIN schema_nodes sn ON sn.id = snp.node_id
366
+ WHERE sn.path = '/interface/wifi-qcom'
367
+ `).all() as Array<{ version: string }>;
368
+ expect(wifiQcom.length).toBe(1);
369
+ expect(wifiQcom[0].version).toBe("7.99-fixture");
370
+
371
+ testDb.close();
372
+ });
373
+ });