@yuneta/gobj-ui 7.26.26 → 7.26.28

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.
Files changed (44) hide show
  1. package/README.md +473 -19
  2. package/dist/gobj-ui.cjs.js +1100 -231
  3. package/dist/gobj-ui.es.js +1101 -232
  4. package/package.json +1 -1
  5. package/scripts/validate-locales.mjs +2334 -275
  6. package/src/c_g6_nodes_tree.js +1143 -260
  7. package/src/c_yui_form.js +11 -1
  8. package/src/c_yui_fsm_graph.js +5 -0
  9. package/src/c_yui_gclass.js +11 -0
  10. package/src/c_yui_gobj_tree_js.js +17 -0
  11. package/src/c_yui_json_graph.js +5 -0
  12. package/src/c_yui_nav.js +9 -6
  13. package/src/c_yui_pager.js +1 -1
  14. package/src/c_yui_period.js +11 -4
  15. package/src/c_yui_schema_editor.js +19 -7
  16. package/src/c_yui_shell.js +7 -7
  17. package/src/c_yui_treedb_graph.js +15 -6
  18. package/src/c_yui_treedb_schema.js +5 -0
  19. package/src/c_yui_treedb_topic_with_form.js +10 -10
  20. package/src/c_yui_treedb_topics.js +6 -6
  21. package/src/c_yui_window.js +2 -2
  22. package/src/c_yui_wizard.js +8 -1
  23. package/src/form_json_field.js +7 -0
  24. package/src/g6_canvas_tab_order.js +84 -0
  25. package/src/g6_canvas_tab_order.wiring.test.js +232 -0
  26. package/src/g6_nodes_tree_controls.test.js +547 -0
  27. package/src/g6_nodes_tree_focus.wiring.test.js +1360 -0
  28. package/src/g6_nodes_tree_i18n_keys.test.js +275 -0
  29. package/src/i18n_key_functions.js +146 -0
  30. package/src/i18n_key_functions.test.js +1204 -0
  31. package/src/lib_graph.js +38 -1
  32. package/src/nav_backbar_name.wiring.test.js +82 -0
  33. package/src/schema_flags.js +47 -0
  34. package/src/schema_import.js +7 -0
  35. package/src/shell_modals.js +20 -16
  36. package/src/shell_route_map.js +12 -2
  37. package/src/wizard_helpers.js +8 -0
  38. package/src/yui_dev.js +44 -33
  39. package/src/yui_dev_controls.test.js +174 -0
  40. package/src/yui_gclass_view.js +2 -2
  41. package/src/yui_graph_camera.js +1 -1
  42. package/src/yui_install.js +10 -8
  43. package/src/yui_table_select.js +2 -2
  44. package/src/yui_time.js +18 -3
@@ -0,0 +1,1204 @@
1
+ /***********************************************************************
2
+ * i18n_key_functions.test.js
3
+ *
4
+ * Holds the library to what a consumer's validate-locales can see.
5
+ *
6
+ * A key reaches the app's locales only by a path the validator
7
+ * reads: a literal (`t("save")`, `i18n: "save"`), a const it follows
8
+ * to one, a literal handed to a function of I18N_KEY_FUNCTIONS at one
9
+ * of its key positions, or an exported `<NAME>_I18N_KEYS` list of the
10
+ * module. Anything else -- a key in a `let`, a table, a computed name
11
+ * -- renders raw and never changes language, with the build green.
12
+ * That shipped (REV-CLOUD V20.5.1: "expand loaded", "click to load",
13
+ * "please select some row", ...).
14
+ *
15
+ * So, read as syntax trees with the validator's own reader:
16
+ *
17
+ * - the table names functions that exist where it says, with
18
+ * positions it can follow;
19
+ * - THE RULE, per call, in every module: each value a key position
20
+ * of a key function can take is a string literal (or nothing), a
21
+ * const the reader follows to one, a key position of the
22
+ * enclosing table function handed on, or it is MARKED where it is
23
+ * handed -- a comment `i18n: <NAME>_I18N_KEYS` (the module
24
+ * publishes those keys) or `i18n: data` (no key a list can hold).
25
+ * Anything else fails, naming the file and the line;
26
+ * - every marker is one and stands where a key is read; the lists
27
+ * it names are published by its module or one it imports; the
28
+ * keys it marks that can be read are in them -- a marked `let`
29
+ * included (or a member or an element read from one), whose
30
+ * every literal is read, and which fails when it holds nothing
31
+ * but literals (a const not written as one);
32
+ * - every published list is readable (exported, an array of
33
+ * literals), lower-case ASCII, has no duplicate and no stale key;
34
+ * - a list a module derives from its SOURCE (a table, a default, a
35
+ * value it returns or assigns) covers it (COVERAGE), the source
36
+ * read with the validator's reader -- a const followed, a `let`
37
+ * into everything it is given -- and a value it cannot follow
38
+ * there fails, with the file and the line, unless the entry
39
+ * names it as data; every other list is named by at least one
40
+ * marker: no list is an orphan;
41
+ * - every key of every list is PINNED: dropping it from the list
42
+ * fails COVERAGE or the marker check, or the module reads it
43
+ * anyway (REV-CLOUD V20: 17 keys were pinned by nothing).
44
+ *
45
+ * Copyright (c) 2026, ArtGins.
46
+ * All Rights Reserved.
47
+ ***********************************************************************/
48
+ import {describe, test, expect} from "vitest";
49
+ import {readFileSync, readdirSync, existsSync} from "node:fs";
50
+ import {dirname, join} from "node:path";
51
+ import {fileURLToPath} from "node:url";
52
+
53
+ import {I18N_KEY_FUNCTIONS} from "./i18n_key_functions.js";
54
+ import {
55
+ parse_source,
56
+ walk,
57
+ line_of,
58
+ source_keys,
59
+ leaves_of,
60
+ values_at,
61
+ key_calls,
62
+ call_rule_violations,
63
+ published_lists,
64
+ scan_ast,
65
+ lib_imports
66
+ } from "../scripts/validate-locales.mjs";
67
+
68
+ const SRC = dirname(fileURLToPath(import.meta.url));
69
+
70
+ const MODULES = readdirSync(SRC)
71
+ .filter((f) => f.endsWith(".js") && !f.endsWith(".test.js"))
72
+ .sort();
73
+
74
+ /* A module's code parsed, knowing its name (for the messages) */
75
+ function parse_as(code, module)
76
+ {
77
+ let ast = parse_source(code);
78
+ Object.defineProperty(ast, "module", {value: module, enumerable: false});
79
+ return ast;
80
+ }
81
+
82
+ const AST = new Map();
83
+ function ast_of(module)
84
+ {
85
+ if(!AST.has(module)) {
86
+ AST.set(module, parse_as(readFileSync(join(SRC, module), "utf8"), module));
87
+ }
88
+ return AST.get(module);
89
+ }
90
+
91
+ /* Every list the library publishes: name -> {module, keys} */
92
+ function all_lists()
93
+ {
94
+ let out = new Map();
95
+ for(let module of MODULES) {
96
+ for(let list of published_lists(ast_of(module)).lists) {
97
+ out.set(list.name, {module, keys: list.keys});
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function rule_report(ast, module)
104
+ {
105
+ return call_rule_violations(ast, module).map((v) =>
106
+ `${module}:${v.line}${v.call_line !== v.line? ` (call at ${v.call_line})` : ""} ` +
107
+ `${v.name}() [${v.path}]: ${v.text.replace(/\s+/g, " ")}`);
108
+ }
109
+
110
+ function declares_function(ast, name)
111
+ {
112
+ let found = false;
113
+ walk(ast, (n) => {
114
+ if(n.type === "FunctionDeclaration" && n.id && n.id.name === name) {
115
+ found = true;
116
+ } else if(n.type === "VariableDeclarator" && n.id.type === "Identifier" && n.id.name === name
117
+ && n.init && /Function/.test(n.init.type)) {
118
+ found = true;
119
+ }
120
+ });
121
+ return found;
122
+ }
123
+
124
+ function exported_names(ast)
125
+ {
126
+ let names = new Set();
127
+ for(let node of ast.body) {
128
+ if(node.type !== "ExportNamedDeclaration" || node.source) {
129
+ continue;
130
+ }
131
+ if(node.declaration) {
132
+ if(node.declaration.id) {
133
+ names.add(node.declaration.id.name);
134
+ }
135
+ for(let d of node.declaration.declarations || []) {
136
+ names.add(d.id.name);
137
+ }
138
+ }
139
+ for(let s of node.specifiers) {
140
+ names.add(s.exported.name || s.exported.value);
141
+ }
142
+ }
143
+ return names;
144
+ }
145
+
146
+ /*
147
+ * What a SOURCE of a list holds, read with the validator's own reader
148
+ * in the scope each value is written in: a const is followed to its
149
+ * value, a `let` into everything it is given (source_keys()).
150
+ *
151
+ * {keys: [string], unread: [{where, text, holds}]}
152
+ *
153
+ * `unread` is every value the reader cannot follow there (a
154
+ * parameter, a call, data), as the place it is read ("<file>:<line>"
155
+ * and its text) and, when that is a `let`, `holds`: the value inside
156
+ * it that stops the reader. A list derived from a source with such a
157
+ * value fails its COVERAGE, unless the entry names that text as data.
158
+ */
159
+ function new_source()
160
+ {
161
+ return {keys: [], unread: []};
162
+ }
163
+
164
+ function text_of(ast, n)
165
+ {
166
+ return ast.code.slice(n.start, n.end).replace(/\s+/g, " ").slice(0, 120);
167
+ }
168
+
169
+ function read_source(src, ast, node, scope)
170
+ {
171
+ let r = source_keys(node, scope);
172
+ src.keys.push(...r.keys);
173
+ let label = ast.module || "(source)";
174
+ for(let u of r.unread) {
175
+ src.unread.push({
176
+ where: `${label}:${line_of(ast, u.at.start)}`,
177
+ text: text_of(ast, u.at),
178
+ holds: (u.node !== u.at)? `${text_of(ast, u.node)} (line ${line_of(ast, u.node.start)})` : null
179
+ });
180
+ }
181
+ return src;
182
+ }
183
+
184
+ function merged(...sources)
185
+ {
186
+ let out = new_source();
187
+ for(let s of sources) {
188
+ out.keys.push(...s.keys);
189
+ out.unread.push(...s.unread);
190
+ }
191
+ out.keys = [...new Set(out.keys)];
192
+ return out;
193
+ }
194
+
195
+ function is_named_property(n, field)
196
+ {
197
+ return n.type === "Property" && !n.computed && (n.key.name === field || n.key.value === field);
198
+ }
199
+
200
+ /* The first declarator of `name` in the module, with its ancestors */
201
+ function declarator_of(ast, name)
202
+ {
203
+ let found = null;
204
+ walk(ast, (n, ancestors) => {
205
+ if(!found && n.type === "VariableDeclarator" && n.id.type === "Identifier" && n.id.name === name) {
206
+ found = {decl: n, scope: [...ancestors, n]};
207
+ }
208
+ });
209
+ if(!found || !found.decl.init) {
210
+ throw new Error(`no const ${name} with a value`);
211
+ }
212
+ return found;
213
+ }
214
+
215
+ /*
216
+ * What a TABLE of a module holds, read from its declaration (never
217
+ * run): the first `const <name>` of the module. With `field`, the
218
+ * values of every property of that name in it; without, every value
219
+ * it holds (not its property names), into nested objects and arrays.
220
+ */
221
+ function table_strings(ast, name, field)
222
+ {
223
+ let src = new_source();
224
+ let {decl, scope} = declarator_of(ast, name);
225
+ if(field) {
226
+ /* A row given by name (`"spiral": SPIRAL_ROW`) or a table spread
227
+ * in (`...EXTRA_LAYOUTS`) is followed to its const; one that
228
+ * cannot be followed fails, as an unread value. */
229
+ let unread = (node) => {
230
+ src.unread.push({
231
+ where: `${ast.module || "(source)"}:${line_of(ast, node.start)}`,
232
+ text: text_of(ast, node),
233
+ holds: null
234
+ });
235
+ };
236
+ let const_of = (node) => {
237
+ let found = null;
238
+ try {
239
+ found = declarator_of(ast, node.name);
240
+ } catch(e) {
241
+ return null;
242
+ }
243
+ let declaration = found.scope[found.scope.length - 2];
244
+ if(!declaration || declaration.kind !== "const") {
245
+ return null;
246
+ }
247
+ return found;
248
+ };
249
+ let read_row = (node, chain) => {
250
+ if(node.type === "Identifier") {
251
+ let found = const_of(node);
252
+ if(!found || found.decl.init.type !== "ObjectExpression") {
253
+ unread(node);
254
+ return;
255
+ }
256
+ read_row(found.decl.init, found.scope);
257
+ return;
258
+ }
259
+ if(node.type !== "ObjectExpression") {
260
+ unread(node);
261
+ return;
262
+ }
263
+ walk(node, (n, ancestors) => {
264
+ if(is_named_property(n, field) && ancestors[ancestors.length - 1].type === "ObjectExpression") {
265
+ read_source(src, ast, n.value, [...ancestors, n]);
266
+ }
267
+ }, [...chain]);
268
+ };
269
+ let read_rows = (node, chain) => {
270
+ if(node.type === "Identifier") {
271
+ let found = const_of(node);
272
+ if(!found) {
273
+ unread(node);
274
+ return;
275
+ }
276
+ read_rows(found.decl.init, found.scope);
277
+ } else if(node.type === "CallExpression" && node.callee.type === "MemberExpression"
278
+ && node.callee.object.name === "Object" && node.callee.property.name === "freeze") {
279
+ read_rows(node.arguments[0], [...chain, node]);
280
+ } else if(node.type === "ObjectExpression") {
281
+ for(let p of node.properties) {
282
+ if(p.type === "SpreadElement") {
283
+ read_rows(p.argument, [...chain, node, p]);
284
+ } else {
285
+ read_row(p.value, [...chain, node, p]);
286
+ }
287
+ }
288
+ } else if(node.type === "ArrayExpression") {
289
+ for(let el of node.elements) {
290
+ if(!el) {
291
+ continue;
292
+ }
293
+ if(el.type === "SpreadElement") {
294
+ read_rows(el.argument, [...chain, node, el]);
295
+ } else {
296
+ read_row(el, [...chain, node]);
297
+ }
298
+ }
299
+ } else {
300
+ unread(node);
301
+ }
302
+ };
303
+ read_rows(decl.init, scope);
304
+ return src;
305
+ }
306
+ let read_table = (node, chain) => {
307
+ if(node.type === "ObjectExpression") {
308
+ for(let p of node.properties) {
309
+ if(p.type === "SpreadElement") {
310
+ read_source(src, ast, p.argument, [...chain, node, p]);
311
+ } else {
312
+ read_table(p.value, [...chain, node, p]);
313
+ }
314
+ }
315
+ } else if(node.type === "ArrayExpression") {
316
+ for(let el of node.elements) {
317
+ if(el) {
318
+ read_table(el.type === "SpreadElement"? el.argument : el, [...chain, node]);
319
+ }
320
+ }
321
+ } else if(node.type === "CallExpression" && node.callee.type === "MemberExpression"
322
+ && node.callee.object.name === "Object" && node.callee.property.name === "freeze") {
323
+ read_table(node.arguments[0], [...chain, node]);
324
+ } else {
325
+ read_source(src, ast, node, chain);
326
+ }
327
+ };
328
+ read_table(decl.init, scope);
329
+ return src;
330
+ }
331
+
332
+ /* The values written as a property `field` of an object, anywhere,
333
+ * every branch of each (`label: x || "app"`) */
334
+ function property_keys(ast, field)
335
+ {
336
+ let src = new_source();
337
+ walk(ast, (n, ancestors) => {
338
+ if(is_named_property(n, field) && ancestors[ancestors.length - 1].type === "ObjectExpression") {
339
+ read_source(src, ast, n.value, [...ancestors, n]);
340
+ }
341
+ });
342
+ return src;
343
+ }
344
+
345
+ /* What a function of the module RETURNS, every branch of each
346
+ * `return` (not those of a function nested in it) */
347
+ function returned_strings(ast, name)
348
+ {
349
+ let src = new_source();
350
+ let fn = null;
351
+ let outer = null;
352
+ walk(ast, (n, ancestors) => {
353
+ if(!fn && n.type === "FunctionDeclaration" && n.id && n.id.name === name) {
354
+ fn = n;
355
+ outer = [...ancestors, n];
356
+ }
357
+ });
358
+ if(!fn) {
359
+ throw new Error(`no function ${name}`);
360
+ }
361
+ walk(fn.body, (n, ancestors) => {
362
+ if(n.type === "ReturnStatement" && n.argument
363
+ && !ancestors.slice(outer.length).some((a) => /Function/.test(a.type))) {
364
+ read_source(src, ast, n.argument, [...ancestors, n]);
365
+ }
366
+ }, outer);
367
+ return src;
368
+ }
369
+
370
+ /* What the module GIVES to a variable or a property `name`:
371
+ * `let name = v`, `name = v`, `x.name = v`, every branch of `v` */
372
+ function assigned_strings(ast, name)
373
+ {
374
+ let src = new_source();
375
+ walk(ast, (n, ancestors) => {
376
+ if(n.type === "VariableDeclarator" && n.id.type === "Identifier" && n.id.name === name && n.init) {
377
+ read_source(src, ast, n.init, [...ancestors, n]);
378
+ } else if(n.type === "AssignmentExpression") {
379
+ let l = n.left;
380
+ let named = (l.type === "Identifier" && l.name === name)
381
+ || (l.type === "MemberExpression" && !l.computed && l.property.name === name);
382
+ if(named) {
383
+ read_source(src, ast, n.right, [...ancestors, n]);
384
+ }
385
+ }
386
+ });
387
+ return src;
388
+ }
389
+
390
+ /* What is given as argument `index` of every call of `callee` */
391
+ function call_strings(ast, callee, index)
392
+ {
393
+ let src = new_source();
394
+ walk(ast, (n, ancestors) => {
395
+ if(n.type === "CallExpression" && n.callee.type === "Identifier" && n.callee.name === callee
396
+ && n.arguments[index]) {
397
+ read_source(src, ast, n.arguments[index], [...ancestors, n]);
398
+ }
399
+ });
400
+ return src;
401
+ }
402
+
403
+ /* The defaults of the attrs whose name matches `re` (SDATA(type, name, flag, default, ...)) */
404
+ function attr_defaults(ast, re)
405
+ {
406
+ let src = new_source();
407
+ walk(ast, (n, ancestors) => {
408
+ if(n.type === "CallExpression" && n.callee.type === "Identifier" && n.callee.name === "SDATA"
409
+ && n.arguments[1] && re.test(String(n.arguments[1].value)) && n.arguments[3]) {
410
+ read_source(src, ast, n.arguments[3], [...ancestors, n]);
411
+ }
412
+ });
413
+ return src;
414
+ }
415
+
416
+ /*
417
+ * The lists a module derives from its SOURCE -- a table, a default, a
418
+ * literal it returns or assigns -- each one held to it, so a key added
419
+ * there without the list fails here (a literal, a const, a `let` the
420
+ * reader follows), and so does a key dropped from the list. `from`
421
+ * reads that source (a source(): its keys, and what it cannot read);
422
+ * `data` names, by the text of the place it is read, each value of it
423
+ * that is not a key of the list and cannot be read -- with why. Any
424
+ * other value the reader cannot follow fails the coverage, naming the
425
+ * file and the line, and so does a `data` entry that names nothing.
426
+ * `test` names the test file that holds the list instead (it lives in
427
+ * a module this test does not own).
428
+ */
429
+ const COVERAGE = [
430
+ {module: "c_yui_gclass.js", list: "C_YUI_GCLASS_I18N_KEYS",
431
+ from: (ast) => merged(table_strings(ast, "VIEWS", "key"), table_strings(ast, "MACHINE_VIEWS", "key"),
432
+ table_strings(ast, "ZONES", "key"))},
433
+ {module: "c_yui_gobj_tree_js.js", list: "C_YUI_GOBJ_TREE_JS_I18N_KEYS",
434
+ from: (ast) => table_strings(ast, "LAYOUTS", "key")},
435
+ {module: "c_yui_period.js", list: "C_YUI_PERIOD_I18N_KEYS",
436
+ from: (ast) => merged(table_strings(ast, "MODE_SPAN"), table_strings(ast, "MODE_CUSTOM"))},
437
+ {module: "yui_time.js", list: "YUI_TIME_I18N_KEYS",
438
+ from: (ast) => merged(table_strings(ast, "YUI_PERIODS", "id"), table_strings(ast, "YUI_ROLLING", "label"))},
439
+ {module: "schema_flags.js", list: "SCHEMA_FLAGS_I18N_KEYS",
440
+ from: (ast) => merged(table_strings(ast, "FLAG_GROUPS"), table_strings(ast, "FLAG_CATALOG", "desc"),
441
+ property_keys(ast, "group"))},
442
+ {module: "schema_import.js", list: "SCHEMA_IMPORT_I18N_KEYS",
443
+ from: (ast) => property_keys(ast, "op")},
444
+ {module: "c_yui_treedb_graph.js", list: "C_YUI_TREEDB_GRAPH_I18N_KEYS",
445
+ from: (ast) => table_strings(ast, "NODE_MODE_LABELS")},
446
+ {module: "c_yui_wizard.js", list: "C_YUI_WIZARD_I18N_KEYS",
447
+ from: (ast) => attr_defaults(ast, /_label$/)},
448
+ {module: "shell_modals.js", list: "SHELL_MODALS_I18N_KEYS",
449
+ from: (ast) => table_strings(ast, "DEFAULT_LABEL")},
450
+ {module: "yui_install.js", list: "YUI_INSTALL_I18N_KEYS",
451
+ from: (ast) => table_strings(ast, "DEFAULT_ASK")},
452
+ /* The severities the checks of schema_validate.js write, which the
453
+ * editor's report translates */
454
+ {module: "c_yui_schema_editor.js", list: "C_YUI_SCHEMA_EDITOR_I18N_KEYS",
455
+ from: () => call_strings(ast_of("schema_validate.js"), "add", 0)},
456
+ {module: "c_yui_form.js", list: "C_YUI_FORM_I18N_KEYS",
457
+ from: (ast) => merged(returned_strings(ast, "field_validation_key"), assigned_strings(ast, "warning")),
458
+ data: {"$input.yui_json_error": "the json field's own error: FORM_JSON_FIELD_I18N_KEYS (form_json_field.js)"}},
459
+ {module: "form_json_field.js", list: "FORM_JSON_FIELD_I18N_KEYS",
460
+ from: (ast) => property_keys(ast, "error")},
461
+ {module: "shell_route_map.js", list: "SHELL_ROUTE_MAP_I18N_KEYS",
462
+ from: (ast) => property_keys(ast, "label"),
463
+ data: {"map.brand.label": "the app's brand label: its shell declaration, which validate-locales reads"}},
464
+ {module: "wizard_helpers.js", list: "WIZARD_HELPERS_I18N_KEYS",
465
+ from: (ast) => assigned_strings(ast, "primary_label"),
466
+ data: {"opts.confirm_label": "the host's own label, a key of the host",
467
+ "opts.next_label": "the host's own label, a key of the host"}},
468
+ {module: "c_g6_nodes_tree.js", list: "G6_NODES_TREE_I18N_KEYS",
469
+ from: (ast) => merged(table_strings(ast, "NODE_SHAPES"), table_strings(ast, "PORT_SHAPES")),
470
+ test: "g6_nodes_tree_i18n_keys.test.js"},
471
+ {module: "yui_dev.js", list: "YUI_DEV_I18N_KEYS",
472
+ test: "yui_dev_controls.test.js"},
473
+ ];
474
+
475
+ /*
476
+ * What is wrong with the coverage of a list: a value of its source
477
+ * the reader cannot follow and the entry does not name as data, a
478
+ * `data` entry that names nothing, a key of the source the list
479
+ * lacks. `ast` is the module's (a mutated copy, in a test).
480
+ */
481
+ function missing_from_list(entry, lists, ast = ast_of(entry.module))
482
+ {
483
+ let list = lists.get(entry.list);
484
+ if(!list || list.module !== entry.module) {
485
+ return [`${entry.module} publishes no ${entry.list}`];
486
+ }
487
+ let src = entry.from(ast);
488
+ let data = entry.data || {};
489
+ let wrong = [];
490
+ let named = new Set();
491
+ for(let u of src.unread) {
492
+ if(Object.prototype.hasOwnProperty.call(data, u.text)) {
493
+ named.add(u.text);
494
+ continue;
495
+ }
496
+ wrong.push(`${u.where}: ${entry.list} is derived from this source, and the reader cannot ` +
497
+ `follow ${u.text}${u.holds? `, which holds ${u.holds}` : ""}: write the key as a ` +
498
+ `literal or a const, or name it as data in its COVERAGE entry`);
499
+ }
500
+ for(let text of Object.keys(data)) {
501
+ if(!named.has(text)) {
502
+ wrong.push(`${entry.module} ${entry.list}: COVERAGE names ${JSON.stringify(text)} as data, ` +
503
+ `and its source reads no such value`);
504
+ }
505
+ }
506
+ let published = new Set(list.keys);
507
+ for(let k of new Set(src.keys)) {
508
+ if(!published.has(k)) {
509
+ wrong.push(`${entry.module} ${entry.list}: ${JSON.stringify(k)} is in its source, not in the list`);
510
+ }
511
+ }
512
+ return wrong;
513
+ }
514
+
515
+ /*
516
+ * The keys of the lists that nothing PINS. A key the module reads
517
+ * anyway reaches a consumer without its list. Any other one reaches
518
+ * it ONLY through the list, so dropping it from the list must fail a
519
+ * check here: the list's COVERAGE, or the marker check -- a key a
520
+ * marker follows (a const, a marked `let`), when no other list that
521
+ * marker names holds it. `coverage` is COVERAGE, or part of it (to
522
+ * test this check).
523
+ */
524
+ const SCANS = new Map();
525
+ function scan_of(module)
526
+ {
527
+ if(!SCANS.has(module)) {
528
+ SCANS.set(module, scan_ast(ast_of(module), {lib_module: module}));
529
+ }
530
+ return SCANS.get(module);
531
+ }
532
+
533
+ function unpinned_keys(lists, coverage)
534
+ {
535
+ let pinned = new Set(); // "<list> <key>"
536
+ let whole = new Set(); // the lists a test of their own holds
537
+ for(let entry of coverage) {
538
+ if(entry.test) {
539
+ whole.add(entry.list);
540
+ }
541
+ if(entry.from) {
542
+ for(let k of entry.from(ast_of(entry.module)).keys) {
543
+ pinned.add(`${entry.list} ${k}`);
544
+ }
545
+ }
546
+ }
547
+ for(let module of MODULES) {
548
+ for(let ref of scan_of(module).marker_refs) {
549
+ for(let k of ref.keys) {
550
+ let holders = ref.lists.filter((name) => lists.has(name) && lists.get(name).keys.includes(k));
551
+ if(holders.length === 1) {
552
+ pinned.add(`${holders[0]} ${k}`);
553
+ }
554
+ }
555
+ }
556
+ }
557
+ let out = [];
558
+ for(let [name, list] of lists) {
559
+ if(whole.has(name)) {
560
+ continue;
561
+ }
562
+ let read = scan_of(list.module).read_keys;
563
+ for(let k of list.keys) {
564
+ if(!read.has(k) && !pinned.has(`${name} ${k}`)) {
565
+ out.push(`${list.module} ${name}: ${JSON.stringify(k)} reaches a consumer only ` +
566
+ `through this list, and dropping it from the list fails no check`);
567
+ }
568
+ }
569
+ }
570
+ return out;
571
+ }
572
+
573
+ /* A copy of the lists with one key dropped from one of them */
574
+ function with_key_dropped(lists, name, key)
575
+ {
576
+ let copy = new Map(lists);
577
+ let list = lists.get(name);
578
+ copy.set(name, {module: list.module, keys: list.keys.filter((k) => k !== key)});
579
+ return copy;
580
+ }
581
+
582
+
583
+ describe("I18N_KEY_FUNCTIONS: the table", () => {
584
+ test("every entry names a function of its module, at positions that can be followed", () => {
585
+ let wrong = [];
586
+ let exported_seen = new Set();
587
+ for(let e of I18N_KEY_FUNCTIONS) {
588
+ let where = `${e.module} ${e.name}`;
589
+ if(!existsSync(join(SRC, e.module))) {
590
+ wrong.push(`${where}: no such module`);
591
+ continue;
592
+ }
593
+ let ast = ast_of(e.module);
594
+ if(!declares_function(ast, e.name)) {
595
+ wrong.push(`${where}: the module declares no such function`);
596
+ }
597
+ if(typeof e.exported !== "boolean") {
598
+ wrong.push(`${where}: \`exported\` is not a boolean`);
599
+ }
600
+ if(e.exported) {
601
+ if(!exported_names(ast).has(e.name)) {
602
+ wrong.push(`${where}: marked exported, and the module does not export it`);
603
+ }
604
+ if(exported_seen.has(e.name)) {
605
+ wrong.push(`${where}: an exported name is matched by NAME, and it is twice in the table`);
606
+ }
607
+ exported_seen.add(e.name);
608
+ }
609
+ if(!Array.isArray(e.keys) || e.keys.length === 0) {
610
+ wrong.push(`${where}: no key position`);
611
+ continue;
612
+ }
613
+ for(let k of e.keys) {
614
+ if(!/^\d+(\[\])?(\.[\w$]+(\[\])?)*$/.test(k)) {
615
+ wrong.push(`${where}: key position ${JSON.stringify(k)} cannot be followed`);
616
+ }
617
+ }
618
+ }
619
+ expect(wrong).toEqual([]);
620
+ });
621
+ });
622
+
623
+ describe("the rule, per call: a key position gets what the validator can read, or a marker", () => {
624
+ test("every call of a key function, in every module of the library", () => {
625
+ let wrong = [];
626
+ for(let module of MODULES) {
627
+ wrong.push(...rule_report(ast_of(module), module));
628
+ }
629
+ expect(wrong).toEqual([]);
630
+ });
631
+
632
+ test("every marker is one, stands where a key is read, and names lists its module can reach", () => {
633
+ let lists = all_lists();
634
+ let wrong = [];
635
+ for(let module of MODULES) {
636
+ let ast = ast_of(module);
637
+ let r = scan_ast(ast, {lib_module: module});
638
+ for(let e of r.marker_errors) {
639
+ wrong.push(`${module}:${e.line}: ${e.why}`);
640
+ }
641
+ let reach = new Set([module, ...lib_imports(ast, new Map(), module)]);
642
+ for(let ref of r.marker_refs) {
643
+ let keys = new Set();
644
+ for(let name of ref.lists) {
645
+ let list = lists.get(name);
646
+ if(!list) {
647
+ wrong.push(`${module}:${ref.line}: the marker names ${name}, which no module publishes`);
648
+ continue;
649
+ }
650
+ if(!reach.has(list.module)) {
651
+ wrong.push(`${module}:${ref.line}: the marker names ${name} of ${list.module}, ` +
652
+ `which this module does not import (a consumer would not read it)`);
653
+ }
654
+ for(let k of list.keys) {
655
+ keys.add(k);
656
+ }
657
+ }
658
+ for(let k of ref.keys) {
659
+ if(!keys.has(k)) {
660
+ wrong.push(`${module}:${ref.line}: ${JSON.stringify(k)} is marked as a key of ` +
661
+ `${ref.lists.join(", ")}, and is not in it`);
662
+ }
663
+ }
664
+ }
665
+ }
666
+ expect(wrong).toEqual([]);
667
+ });
668
+ });
669
+
670
+ describe("the reader the rule and the validator share", () => {
671
+ let module = "c_yui_treedb_topics.js"; // card_action_anchor: [2]
672
+ let rule = (code) => rule_report(parse_source(code), module);
673
+ let keys = (code) => [...scan_ast(parse_source(code), {lib_module: module}).keys];
674
+
675
+ test("literals, branches, nothing", () => {
676
+ expect(rule(`card_action_anchor(a, b, ok ? "x" : "y"); card_action_anchor(a, b, c || "z");`))
677
+ .toEqual([`${module}:1 card_action_anchor() [2]: c`]);
678
+ expect(rule(`card_action_anchor(a, b); card_action_anchor(a, b, null); card_action_anchor(a, b, flag && "w");`))
679
+ .toEqual([]);
680
+ });
681
+
682
+ test("a const is followed -- and its key SEEN -- in the scope that declares it", () => {
683
+ let code = [
684
+ `const TABLE_KEY = "a brand new card key";`,
685
+ `const PICK = {ok: "picked key", no: null};`,
686
+ `function f(TABLE_KEY_2) { card_action_anchor(a, b, TABLE_KEY); card_action_anchor(a, b, PICK.ok); }`,
687
+ `function g(TABLE_KEY) { card_action_anchor(a, b, TABLE_KEY); }`, // a parameter shadows it
688
+ `function h() { let x = "let key"; card_action_anchor(a, b, x); }`, // a let is not followed
689
+ ].join("\n");
690
+ expect(rule(code)).toEqual([
691
+ `${module}:4 card_action_anchor() [2]: TABLE_KEY`,
692
+ `${module}:5 card_action_anchor() [2]: x`
693
+ ]);
694
+ expect(keys(code)).toEqual(expect.arrayContaining(["a brand new card key", "picked key"]));
695
+ expect(keys(code)).not.toContain("let key");
696
+ });
697
+
698
+ test("an element of a const array: .map() and for...of; every value of a computed member", () => {
699
+ let code = [
700
+ `const SHAPES = ["square", "circle"];`,
701
+ `const NOTES = {a: "note a", b: "note b"};`,
702
+ `const ROWS = [{key: "row a"}, {key: "row b"}];`,
703
+ `function f(v) {`,
704
+ ` t(NOTES[v]);`,
705
+ ` for(const row of ROWS) { card_action_anchor(a, b, row.key); }`,
706
+ ` return SHAPES.map((shape) => ({value: shape, i18n: shape}));`,
707
+ `}`
708
+ ].join("\n");
709
+ expect(rule(code)).toEqual([]);
710
+ expect(keys(code).sort()).toEqual(["circle", "note a", "note b", "row a", "row b", "square"]);
711
+ });
712
+
713
+ test("a marker covers what stands right after it: an argument, a branch, the whole call", () => {
714
+ expect(rule([
715
+ `card_action_anchor(a, b, /* i18n: data */ comment);`,
716
+ `card_action_anchor(a, b, ok ? "x" : /* i18n: data */ comment);`,
717
+ `/* i18n: data */ card_action_anchor(a, b, comment);`,
718
+ `card_action_anchor(a, b, ok ? /* i18n: data */ "x" : comment);`, // marks "x", not comment
719
+ ].join("\n"))).toEqual([`${module}:4 card_action_anchor() [2]: comment`]);
720
+
721
+ let r = scan_ast(parse_source([
722
+ `card_action_anchor(a, b, /* i18n: datum */ comment);`,
723
+ `let x = /* i18n: data */ 1;`,
724
+ `card_action_anchor(a, b, /* i18n: C_YUI_FOO_I18N_KEYS */ ok ? "listed" : comment);`
725
+ ].join("\n")), {lib_module: module});
726
+ expect(r.marker_errors.map((e) => e.line)).toEqual([1, 2]);
727
+ expect(r.marker_errors[0].why).toMatch(/is no i18n marker/);
728
+ expect(r.marker_errors[1].why).toMatch(/stands before no place a key is read/);
729
+ expect(r.marker_refs).toEqual([{lists: ["C_YUI_FOO_I18N_KEYS"], keys: ["listed"], line: 3}]);
730
+ });
731
+
732
+ test("a let or var a marker stands before is HELD to it: everything it is given is read", () => {
733
+ let r = scan_ast(parse_source([
734
+ `export const C_YUI_FOO_I18N_KEYS = ["listed", "listed too"];`,
735
+ `function f(x) {`,
736
+ ` let k = x.label || "listed";`,
737
+ ` if(x.other) {`,
738
+ ` k = "a new literal";`,
739
+ ` }`,
740
+ ` card_action_anchor(a, b, /* i18n: C_YUI_FOO_I18N_KEYS */ k);`,
741
+ `}`,
742
+ `function g(state) {`,
743
+ ` let only = state? "one" : "two";`,
744
+ ` if(state === 3) {`,
745
+ ` only = "three";`,
746
+ ` }`,
747
+ ` t(/* i18n: C_YUI_FOO_I18N_KEYS */ only);`,
748
+ `}`,
749
+ `function h(x) {`,
750
+ ` var v = "listed too";`,
751
+ ` var w = v;`,
752
+ ` t(/* i18n: data */ w);`,
753
+ `}`,
754
+ `function shadowed(x) {`,
755
+ ` let s = x.name;`,
756
+ ` function inner(s) { s = "a parameter of another function"; }`,
757
+ ` for(s of x.list) { use(s); }`,
758
+ ` t(/* i18n: C_YUI_FOO_I18N_KEYS */ s);`,
759
+ `}`
760
+ ].join("\n")), {lib_module: module});
761
+
762
+ /* Each literal it is given is a key asked for, and must be in the list named */
763
+ expect(r.keys.has("a new literal")).toBe(true);
764
+ expect(r.marker_refs.find((ref) => ref.line === 7).keys.sort()).toEqual(["a new literal", "listed"]);
765
+
766
+ /* One given nothing but literals -- through another let too -- is a const not written as one */
767
+ expect(r.marker_errors.map((e) => e.line)).toEqual([14, 19]);
768
+ expect(r.marker_errors[0].why).toBe('`only` is a let (line 10) that only ever holds literals ' +
769
+ '("one", "two", "three"): make it a const, which the reader follows, and drop the marker');
770
+ expect(r.marker_errors[1].why).toMatch(/^`w` is a var \(line 18\) that only ever holds literals \("listed too"\)/);
771
+ expect(["one", "two", "three", "listed too"].every((k) => r.keys.has(k))).toBe(true);
772
+
773
+ /* A write it cannot read (a for...of over it) keeps it unknown;
774
+ * another binding of the same name is not a write to it */
775
+ expect(r.marker_refs.find((ref) => ref.line === 25).keys).toEqual([]);
776
+ expect(r.keys.has("a parameter of another function")).toBe(false);
777
+ });
778
+
779
+ test("REV-CLOUD V20 (js3_v lists #1): a marker on a member or an element of a let follows the let", () => {
780
+ let scan = (lines) => scan_ast(parse_source([`export const X_I18N_KEYS = ["in it"];`, ...lines].join("\n")),
781
+ {lib_module: "x.js"});
782
+ let whys = (r) => r.marker_errors.map((e) => `${e.line}: ${e.why}`);
783
+ let marked = (r, line) => r.marker_refs.find((ref) => ref.line === line).keys.sort();
784
+
785
+ /* The review's repro: a let table, a let array walked, a let read from one */
786
+ let r = scan([
787
+ `let LABELS = {a: "in it", b: "a brand new let-table key"};`,
788
+ `function f(k) { return t(/* i18n: X_I18N_KEYS */ LABELS[k]); }`,
789
+ `let LIST = ["in it", "a brand new let-array key"];`,
790
+ `function g() { LIST.forEach((x) => t(/* i18n: X_I18N_KEYS */ x)); }`,
791
+ `let picked = LIST[0];`,
792
+ `function h() { t(/* i18n: X_I18N_KEYS */ picked); }`,
793
+ ]);
794
+ expect(["a brand new let-table key", "a brand new let-array key"].every((k) => r.keys.has(k))).toBe(true);
795
+ expect(whys(r)).toEqual([
796
+ '3: `LABELS` is a let (line 2) that only ever holds literals ("in it", "a brand new let-table key"): ' +
797
+ 'make it a const, which the reader follows, and drop the marker',
798
+ '5: `LIST` is a let (line 4) that only ever holds literals ("in it", "a brand new let-array key"): ' +
799
+ 'make it a const, which the reader follows, and drop the marker',
800
+ '7: `picked` is a let (line 6) that only ever holds literals ("in it", "a brand new let-array key"), ' +
801
+ 'through the let `LIST` (line 4): make them consts, which the reader follows, and drop the marker'
802
+ ]);
803
+
804
+ /* A table that also holds data, or is written INTO: each literal is
805
+ * read and must be in the list, and no "make it a const" (a const
806
+ * would not carry what is written into it) */
807
+ let d = scan([
808
+ `let LABELS = {a: "in it", b: "table key"};`,
809
+ `function f(x, k) { if(x) { LABELS = x.labels; } t(/* i18n: X_I18N_KEYS */ LABELS[k]); }`,
810
+ `let PUSHED = [];`,
811
+ `PUSHED.push("pushed key");`,
812
+ `function g() { for(const x of PUSHED) { t(/* i18n: X_I18N_KEYS */ x); } }`,
813
+ `let NAMED = {a: "in it"};`,
814
+ `NAMED.b = "member key";`,
815
+ `Object.assign(NAMED, {a: "assigned key"});`,
816
+ `function h(k) { t(/* i18n: X_I18N_KEYS */ NAMED[k]); }`,
817
+ `function i() { t(/* i18n: X_I18N_KEYS */ NAMED.c); }`,
818
+ ]);
819
+ expect(whys(d)).toEqual([]);
820
+ expect(marked(d, 3)).toEqual(["in it", "table key"]);
821
+ expect(marked(d, 6)).toEqual(["pushed key"]);
822
+ expect(marked(d, 10)).toEqual(["assigned key", "in it", "member key"]);
823
+ expect(marked(d, 11)).toEqual([]); // NAMED.c: no write reaches it
824
+
825
+ /* The same for a key position of a key function, and a parameter of
826
+ * the same name is not the let */
827
+ let k = scan_ast(parse_source([
828
+ `import {yui_shell_confirm_ok} from "./shell_modals.js";`,
829
+ `export const X_I18N_KEYS = ["in it"];`,
830
+ `let opts = {title: "a let option"};`,
831
+ `function f(shell) { yui_shell_confirm_ok(shell, "in it", /* i18n: X_I18N_KEYS */ opts); }`,
832
+ `let LIST = ["in it", "shadowed"];`,
833
+ `function g(LIST) { LIST.forEach((x) => t(/* i18n: X_I18N_KEYS */ x)); }`,
834
+ ].join("\n")), {lib_module: module});
835
+ expect(k.keys.has("a let option")).toBe(true);
836
+ expect(k.keys.has("shadowed")).toBe(false);
837
+ expect(whys(k)).toEqual(['4: `opts` is a let (line 3) that only ever holds literals ("a let option"): ' +
838
+ 'make it a const, which the reader follows, and drop the marker']);
839
+ });
840
+
841
+ test("a key position of the enclosing table function is handed on", () => {
842
+ let code = [
843
+ `function answer_print_tranger_refused(gobj, x, kw) { card_action_anchor(a, b, kw.i18n); }`,
844
+ `function other(gobj, x, kw) { card_action_anchor(a, b, kw.i18n); }`
845
+ ].join("\n");
846
+ expect(rule(code)).toEqual([`${module}:2 card_action_anchor() [2]: kw.i18n`]);
847
+ });
848
+
849
+ test("options, spreads and Object.assign", () => {
850
+ let src = parse_source([
851
+ `import {yui_shell_confirm_danger} from "./shell_modals.js";`,
852
+ `const BASE = {cancel_label: "base cancel"};`,
853
+ `yui_shell_confirm_danger(shell, "m", Object.assign(opts, {confirm_label: "d"}));`,
854
+ `yui_shell_confirm_danger(shell, "m", {...BASE, title: "t"});`
855
+ ].join("\n"));
856
+ let calls = key_calls(src, "some_module.js");
857
+ let at = (i, path) => values_at(calls[i].call, path, calls[i].scope)
858
+ .map((l) => l.kind === "key"? l.value : l.kind);
859
+ expect(at(0, "2.confirm_label")).toEqual(["unknown", "d"]); // `opts` may hold one too
860
+ expect(at(1, "2.cancel_label")).toEqual(["base cancel"]);
861
+ expect(at(1, "2.title")).toEqual(["t"]);
862
+ expect(leaves_of(parse_source(`x;`).body[0].expression).map((l) => l.kind)).toEqual(["unknown"]);
863
+ });
864
+ });
865
+
866
+ describe("REV-CLOUD V20.5 repro, on the real module", () => {
867
+ let original = readFileSync(join(SRC, "c_yui_treedb_topics.js"), "utf8");
868
+ let call = `actions.push(card_action_anchor(\n "TREEDB_CARD_TABLE", "yi-table", "table", fill(routes.table)));`;
869
+
870
+ test("the call it mutates is there", () => {
871
+ expect(original).toContain(call);
872
+ });
873
+
874
+ test("a const key handed to card_action_anchor is READ: the validator asks for it", () => {
875
+ let mutated = original.replace(call, call.replace(`"table"`, "TABLE_KEY"))
876
+ .replace(`const GCLASS_NAME = "C_YUI_TREEDB_TOPICS";`,
877
+ `const GCLASS_NAME = "C_YUI_TREEDB_TOPICS";\nconst TABLE_KEY = "a brand new card key";`);
878
+ let ast = parse_source(mutated);
879
+ expect(rule_report(ast, "c_yui_treedb_topics.js")).toEqual([]);
880
+ expect(scan_ast(ast, {lib_module: "c_yui_treedb_topics.js"}).keys.has("a brand new card key")).toBe(true);
881
+ });
882
+
883
+ test("a key it cannot read, unmarked, FAILS with the file and the line", () => {
884
+ let mutated = original.replace(call, `let table_key = "table";\n ` +
885
+ call.replace(`"table"`, "table_key"));
886
+ let ast = parse_source(mutated);
887
+ let line = mutated.slice(0, mutated.indexOf("table_key,")).split("\n").length;
888
+ expect(rule_report(ast, "c_yui_treedb_topics.js"))
889
+ .toEqual([`c_yui_treedb_topics.js:${line} (call at ${line - 1}) card_action_anchor() [2]: table_key`]);
890
+
891
+ /* ...and the module publishing a list does not exempt it any more */
892
+ let listed = mutated.replace(`const GCLASS_NAME = "C_YUI_TREEDB_TOPICS";`,
893
+ `const GCLASS_NAME = "C_YUI_TREEDB_TOPICS";\nexport const C_YUI_TREEDB_TOPICS_I18N_KEYS = ["table"];`);
894
+ expect(rule_report(parse_source(listed), "c_yui_treedb_topics.js").length).toBe(1);
895
+
896
+ /* A marker naming the list is what ties the call to it */
897
+ let marked = listed.replace("table_key,", "/* i18n: C_YUI_TREEDB_TOPICS_I18N_KEYS */ table_key,");
898
+ expect(rule_report(parse_source(marked), "c_yui_treedb_topics.js")).toEqual([]);
899
+ });
900
+ });
901
+
902
+ describe("REV-CLOUD V20 (js2_va #1): a marker on a let does not let it pass unread", () => {
903
+ let original = readFileSync(join(SRC, "c_yui_treedb_topic_with_form.js"), "utf8");
904
+ let decl = ` const title = (mode === "create")? "new" : ((mode === "view")? "view" : "edit");`;
905
+ let use = ` title: title,`;
906
+ let lib_module = "c_yui_treedb_topic_with_form.js";
907
+ let scan = (code) => scan_ast(parse_source(code), {lib_module});
908
+
909
+ test("what it mutates is there", () => {
910
+ expect(original).toContain(decl);
911
+ expect(original).toContain(use);
912
+ });
913
+
914
+ test("the review's repro: a new literal branch on the marked let FAILS", () => {
915
+ /* The let and its marker, as they were, plus the review's branch */
916
+ let mutated = original
917
+ .replace(decl, ` let title = (mode === "clone")? "a brand new clone title" :\n` +
918
+ ` (mode === "create")? "new" : ((mode === "view")? "view" : "edit");`)
919
+ .replace(use, ` title: /* i18n: C_YUI_TREEDB_TOPIC_WITH_FORM_I18N_KEYS */ title,`)
920
+ .replace(`const GCLASS_NAME = "C_YUI_TREEDB_TOPIC_WITH_FORM";`,
921
+ `const GCLASS_NAME = "C_YUI_TREEDB_TOPIC_WITH_FORM";\n` +
922
+ `export const C_YUI_TREEDB_TOPIC_WITH_FORM_I18N_KEYS = ["new", "view", "edit"];`);
923
+ let r = scan(mutated);
924
+ expect(r.keys.has("a brand new clone title")).toBe(true);
925
+ expect(r.marker_errors.map((e) => e.why)).toEqual([expect.stringMatching(
926
+ /^`title` is a let \(line \d+\) that only ever holds literals \("a brand new clone title", "new", "view", "edit"\): make it a const/)]);
927
+
928
+ /* ...and a let that also holds data: the new literal must be in the list */
929
+ let mixed = mutated.replace(`(mode === "clone")? "a brand new clone title" :`,
930
+ `(mode === "clone")? "a brand new clone title" : (mode === "custom")? priv.custom_title :`);
931
+ let m = scan(mixed);
932
+ expect(m.marker_errors).toEqual([]);
933
+ let ref = m.marker_refs.find((x) => x.lists.includes("C_YUI_TREEDB_TOPIC_WITH_FORM_I18N_KEYS"));
934
+ expect(ref.keys).toContain("a brand new clone title");
935
+ let listed = new Set(m.lists.find((l) => l.name === "C_YUI_TREEDB_TOPIC_WITH_FORM_I18N_KEYS").keys);
936
+ expect(ref.keys.filter((k) => !listed.has(k))).toEqual(["a brand new clone title"]);
937
+ });
938
+
939
+ test("the module as it is now: a const, and a new branch is READ", () => {
940
+ let mutated = original.replace(decl,
941
+ ` const title = (mode === "clone")? "a brand new clone title" :\n` +
942
+ ` (mode === "create")? "new" : ((mode === "view")? "view" : "edit");`);
943
+ let r = scan(mutated);
944
+ expect(r.keys.has("a brand new clone title")).toBe(true);
945
+ expect(r.marker_errors).toEqual([]);
946
+
947
+ let camera = readFileSync(join(SRC, "yui_graph_camera.js"), "utf8");
948
+ let key = ` const key = (state === "arming")? "click the element to centre on"`;
949
+ expect(camera).toContain(key);
950
+ let c = scan_ast(parse_source(camera.replace(key,
951
+ ` const key = (state === "pinned")? "a brand new camera key"\n : (state === "arming")? "click the element to centre on"`)),
952
+ {lib_module: "yui_graph_camera.js"});
953
+ expect(c.keys.has("a brand new camera key")).toBe(true);
954
+ });
955
+ });
956
+
957
+ describe("the published *_I18N_KEYS lists", () => {
958
+ test("every list is readable (exported, an array of literals), lower-case ASCII, with no duplicate", () => {
959
+ let wrong = [];
960
+ for(let module of MODULES) {
961
+ let r = published_lists(ast_of(module));
962
+ for(let why of r.errors) {
963
+ wrong.push(`${module}: ${why}`);
964
+ }
965
+ for(let list of r.lists) {
966
+ let seen = new Set();
967
+ for(let k of list.keys) {
968
+ if(!/^[\x20-\x7e]+$/.test(k) || k !== k.toLowerCase()) {
969
+ wrong.push(`${module} ${list.name}: ${JSON.stringify(k)} is no key a validated locale can hold`);
970
+ }
971
+ if(seen.has(k)) {
972
+ wrong.push(`${module} ${list.name}: ${JSON.stringify(k)} twice`);
973
+ }
974
+ seen.add(k);
975
+ }
976
+ }
977
+ }
978
+ expect(wrong).toEqual([]);
979
+ });
980
+
981
+ test("no stale key: each one is written elsewhere in its module", () => {
982
+ let wrong = [];
983
+ for(let module of MODULES) {
984
+ let ast = ast_of(module);
985
+ let lists = published_lists(ast).lists;
986
+ if(lists.length === 0) {
987
+ continue;
988
+ }
989
+ /* The ranges of the lists themselves: a key there proves nothing */
990
+ let ranges = [];
991
+ walk(ast, (n) => {
992
+ if(n.type === "VariableDeclarator" && n.id.type === "Identifier"
993
+ && /_I18N_KEYS$/i.test(n.id.name) && n.init) {
994
+ ranges.push([n.init.start, n.init.end]);
995
+ }
996
+ });
997
+ let inside = (n) => ranges.some(([a, b]) => n.start >= a && n.end <= b);
998
+ let literals = new Set();
999
+ walk(ast, (n) => {
1000
+ if(inside(n)) {
1001
+ return;
1002
+ }
1003
+ if(n.type === "Literal" && typeof n.value === "string") {
1004
+ literals.add(n.value);
1005
+ } else if(n.type === "TemplateLiteral" && n.expressions.length === 0) {
1006
+ literals.add(n.quasis[0].value.cooked);
1007
+ }
1008
+ });
1009
+ for(let list of lists) {
1010
+ for(let k of list.keys) {
1011
+ if(!literals.has(k)) {
1012
+ wrong.push(`${module} ${list.name}: ${JSON.stringify(k)} is written nowhere else in the module`);
1013
+ }
1014
+ }
1015
+ }
1016
+ }
1017
+ expect(wrong).toEqual([]);
1018
+ });
1019
+
1020
+ test("a list derived from a table covers it", () => {
1021
+ let lists = all_lists();
1022
+ let wrong = [];
1023
+ for(let entry of COVERAGE) {
1024
+ if(entry.from) {
1025
+ wrong.push(...missing_from_list(entry, lists));
1026
+ }
1027
+ if(entry.test) {
1028
+ let path = join(SRC, entry.test);
1029
+ if(!existsSync(path) || !readFileSync(path, "utf8").includes(entry.list)) {
1030
+ wrong.push(`${entry.list}: its test ${entry.test} does not hold it`);
1031
+ }
1032
+ }
1033
+ }
1034
+ expect(wrong).toEqual([]);
1035
+ });
1036
+
1037
+ test("no orphan list: each one is covered here or named by a marker", () => {
1038
+ let named = new Set(COVERAGE.map((e) => e.list));
1039
+ for(let module of MODULES) {
1040
+ for(let ref of scan_ast(ast_of(module), {lib_module: module}).marker_refs) {
1041
+ for(let name of ref.lists) {
1042
+ named.add(name);
1043
+ }
1044
+ }
1045
+ }
1046
+ let orphans = [...all_lists()].filter(([name]) => !named.has(name))
1047
+ .map(([name, list]) => `${list.module}: ${name} is named by no marker and covered by no test`);
1048
+ expect(orphans).toEqual([]);
1049
+ });
1050
+
1051
+ test("every key of every list is PINNED: dropping it fails a check, or the module reads it anyway", () => {
1052
+ expect(unpinned_keys(all_lists(), COVERAGE)).toEqual([]);
1053
+ });
1054
+
1055
+ test("the pin check fails when it should (REV-CLOUD V20, js2_va #2)", () => {
1056
+ let lists = all_lists();
1057
+
1058
+ /* Without the coverage this round added, the keys the review found
1059
+ * pinned by nothing are reported, each one */
1060
+ let added = ["C_YUI_FORM_I18N_KEYS", "FORM_JSON_FIELD_I18N_KEYS", "SHELL_ROUTE_MAP_I18N_KEYS",
1061
+ "WIZARD_HELPERS_I18N_KEYS", "C_YUI_SCHEMA_EDITOR_I18N_KEYS"];
1062
+ let report = unpinned_keys(lists, COVERAGE.filter((e) => !added.includes(e.list)));
1063
+ let reported = (name, key) => report.some((line) => line.includes(`${name}: ${JSON.stringify(key)} `));
1064
+ for(let [name, key] of [
1065
+ ["C_YUI_FORM_I18N_KEYS", "this field is required"],
1066
+ ["FORM_JSON_FIELD_I18N_KEYS", "invalid json"],
1067
+ ["SHELL_ROUTE_MAP_I18N_KEYS", "toolbar"],
1068
+ ["SHELL_ROUTE_MAP_I18N_KEYS", "other routes"],
1069
+ ["SHELL_ROUTE_MAP_I18N_KEYS", "app"],
1070
+ ["WIZARD_HELPERS_I18N_KEYS", "confirm"],
1071
+ ["WIZARD_HELPERS_I18N_KEYS", "next"],
1072
+ ["C_YUI_SCHEMA_EDITOR_I18N_KEYS", "error"],
1073
+ ["C_YUI_SCHEMA_EDITOR_I18N_KEYS", "warning"]
1074
+ ]) {
1075
+ expect(reported(name, key), `${name} ${key}`).toBe(true);
1076
+ }
1077
+
1078
+ /* ...and with it, the review's repro -- a key dropped from its
1079
+ * list -- fails the coverage of that list */
1080
+ for(let [name, key] of [
1081
+ ["C_YUI_FORM_I18N_KEYS", "this field is required"],
1082
+ ["C_YUI_SCHEMA_EDITOR_I18N_KEYS", "error"],
1083
+ ["C_YUI_SCHEMA_EDITOR_I18N_KEYS", "warning"],
1084
+ ["SHELL_ROUTE_MAP_I18N_KEYS", "other routes"],
1085
+ ["WIZARD_HELPERS_I18N_KEYS", "next"],
1086
+ ["FORM_JSON_FIELD_I18N_KEYS", "invalid json"]
1087
+ ]) {
1088
+ let entry = COVERAGE.find((e) => e.list === name);
1089
+ expect(missing_from_list(entry, with_key_dropped(lists, name, key))).toEqual([
1090
+ `${entry.module} ${name}: ${JSON.stringify(key)} is in its source, not in the list`
1091
+ ]);
1092
+ }
1093
+
1094
+ /* The rest of the review's 17 keys have no list any more: the
1095
+ * module reads them where it writes them */
1096
+ let reads = (module, key) => scan_of(module).read_keys.has(key);
1097
+ for(let [module, key] of [
1098
+ ["c_yui_nav.js", "navigation"],
1099
+ ["c_yui_pager.js", "close"],
1100
+ ["c_yui_schema_editor.js", "delete this column?"],
1101
+ ["c_yui_treedb_topic_with_form.js", "view"],
1102
+ ["c_yui_window.js", "restore"],
1103
+ ["yui_graph_camera.js", "click the element to centre on"],
1104
+ ["yui_graph_camera.js", "centred: click to release"],
1105
+ ["yui_table_select.js", "clear selection"]
1106
+ ]) {
1107
+ expect(reads(module, key), `${module} ${key}`).toBe(true);
1108
+ }
1109
+ });
1110
+
1111
+ test("the coverage and orphan checks fail when they should", () => {
1112
+ let lists = all_lists();
1113
+ /* A row added to a table, its key not to the list */
1114
+ let entry = COVERAGE.find((e) => e.list === "C_YUI_GOBJ_TREE_JS_I18N_KEYS");
1115
+ let ast = parse_as(readFileSync(join(SRC, entry.module), "utf8")
1116
+ .replace(`const LAYOUTS = {`, `const LAYOUTS = {\n "spiral": {key: "spiral layout"},`), entry.module);
1117
+ expect(entry.from(ast).keys).toContain("spiral layout");
1118
+ expect(missing_from_list(entry, lists, ast)).toEqual([
1119
+ `${entry.module} ${entry.list}: "spiral layout" is in its source, not in the list`
1120
+ ]);
1121
+
1122
+ /* A marked key that is not in the list it names */
1123
+ let r = scan_ast(parse_source([
1124
+ `export const X_I18N_KEYS = ["in it"];`,
1125
+ `const K = "not in it";`,
1126
+ `t(/* i18n: X_I18N_KEYS */ K);`
1127
+ ].join("\n")), {lib_module: "x.js"});
1128
+ expect(r.marker_refs).toEqual([{lists: ["X_I18N_KEYS"], keys: ["not in it"], line: 3}]);
1129
+ });
1130
+ });
1131
+
1132
+ describe("REV-CLOUD V20 (js3_v lists #2): a COVERAGE source is read the way the validator reads", () => {
1133
+ let lists = all_lists();
1134
+ let form = COVERAGE.find((e) => e.list === "C_YUI_FORM_I18N_KEYS");
1135
+ let route_map = COVERAGE.find((e) => e.list === "SHELL_ROUTE_MAP_I18N_KEYS");
1136
+ let mutated = (module, edits) => {
1137
+ let code = readFileSync(join(SRC, module), "utf8");
1138
+ for(let [a, b] of edits) {
1139
+ expect(code, `${module}: ${a}`).toContain(a);
1140
+ code = code.replace(a, b);
1141
+ }
1142
+ return parse_as(code, module);
1143
+ };
1144
+ let missing = ` if(v && v.valueMissing) {`;
1145
+ let decl = `function field_validation_key($input)`;
1146
+ let other_routes = ` if(Array.isArray(map.other) && map.other.length) {`;
1147
+
1148
+ test("the review's repro: a const the function returns, a const label it pushes -- each FAILS", () => {
1149
+ let ast = mutated(form.module, [
1150
+ [missing, ` if(v && v.typeMismatch) {\n return TYPE_MISMATCH_KEY;\n }\n${missing}`],
1151
+ [decl, `const TYPE_MISMATCH_KEY = "a brand new type mismatch key";\n${decl}`]
1152
+ ]);
1153
+ expect(missing_from_list(form, lists, ast)).toEqual([
1154
+ `c_yui_form.js C_YUI_FORM_I18N_KEYS: "a brand new type mismatch key" is in its source, not in the list`
1155
+ ]);
1156
+
1157
+ let map = mutated(route_map.module, [
1158
+ [other_routes, ` children.push({label: SHORTCUTS_GROUP, icon: "", route: "", event: "", children: []});\n` +
1159
+ other_routes],
1160
+ [`function build_body(shell, t)`,
1161
+ `const SHORTCUTS_GROUP = "a brand new shortcuts group";\nfunction build_body(shell, t)`]
1162
+ ]);
1163
+ expect(missing_from_list(route_map, lists, map)).toEqual([
1164
+ `shell_route_map.js SHELL_ROUTE_MAP_I18N_KEYS: "a brand new shortcuts group" is in its source, not in the list`
1165
+ ]);
1166
+ });
1167
+
1168
+ test("a let is followed into everything it is given; a value it cannot follow FAILS, with the line", () => {
1169
+ let ast = mutated(form.module, [
1170
+ [missing, ` let k = "a let key";\n if(v && v.badInput) {\n k = "a let key too";\n }\n` +
1171
+ ` if(v && v.typeMismatch) {\n return k;\n }\n${missing}`]
1172
+ ]);
1173
+ expect(missing_from_list(form, lists, ast)).toEqual([
1174
+ `c_yui_form.js C_YUI_FORM_I18N_KEYS: "a let key" is in its source, not in the list`,
1175
+ `c_yui_form.js C_YUI_FORM_I18N_KEYS: "a let key too" is in its source, not in the list`
1176
+ ]);
1177
+
1178
+ let code = readFileSync(join(SRC, form.module), "utf8");
1179
+ let unread = mutated(form.module, [
1180
+ [missing, ` if(v && v.typeMismatch) {\n return $input.dataset.key;\n }\n` +
1181
+ ` let w = $input.custom || "this field is required";\n if(v && v.patternMismatch) {\n` +
1182
+ ` return w;\n }\n${missing}`]
1183
+ ]);
1184
+ let line = code.slice(0, code.indexOf(missing)).split("\n").length;
1185
+ expect(missing_from_list(form, lists, unread)).toEqual([
1186
+ `c_yui_form.js:${line + 1}: C_YUI_FORM_I18N_KEYS is derived from this source, and the reader ` +
1187
+ `cannot follow $input.dataset.key: write the key as a literal or a const, or name it as data ` +
1188
+ `in its COVERAGE entry`,
1189
+ `c_yui_form.js:${line + 5}: C_YUI_FORM_I18N_KEYS is derived from this source, and the reader ` +
1190
+ `cannot follow w, which holds $input.custom (line ${line + 3}): write the key as a literal or a ` +
1191
+ `const, or name it as data in its COVERAGE entry`
1192
+ ]);
1193
+ });
1194
+
1195
+ test("a value named as data that the source no longer reads is stale, and FAILS", () => {
1196
+ let entry = Object.assign({}, form, {data: Object.assign({"$input.gone": "nothing reads it"}, form.data)});
1197
+ expect(missing_from_list(entry, lists)).toEqual([
1198
+ `c_yui_form.js C_YUI_FORM_I18N_KEYS: COVERAGE names "$input.gone" as data, and its source reads no such value`
1199
+ ]);
1200
+ let none = Object.assign({}, form, {data: {}});
1201
+ expect(missing_from_list(none, lists)).toEqual([expect.stringMatching(
1202
+ /^c_yui_form\.js:\d+: C_YUI_FORM_I18N_KEYS is derived from this source, and the reader cannot follow \$input\.yui_json_error:/)]);
1203
+ });
1204
+ });