1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148 | // The three catalogs.
//
// `t` renders an unknown key AS THE KEY — visible and greppable, which is the
// right failure for a developer and a terrible one for a user, who gets
// `err.pay.seq-spent` where a sentence should be. English is the fallback, so a
// key missing from EN is missing everywhere; a key missing from RO or HU falls
// back silently and ships a bilingual screen nobody notices.
//
// So: the key SETS must be identical, and every message must be a usable one.
// (test/view.test.mjs asserts the other direction — that every key the
// interface can actually ask for is defined here.)
//
// Runs against test-dist/testlib.js — the built output — never src/.
import test from "node:test";
import assert from "node:assert/strict";
import * as L from "../test-dist/testlib.js";
const LANGS = ["en", "ro", "hu"];
const keysOf = (lang) => [...L.catalogKeys(lang)];
test("every catalog defines exactly the same keys", () => {
const en = keysOf("en");
assert.ok(en.length > 100, `only ${en.length} keys — the catalog did not load`);
for (const lang of LANGS.slice(1)) {
const have = keysOf(lang);
assert.deepEqual(have.filter((k) => !en.includes(k)), [], `${lang} defines keys EN does not`);
assert.deepEqual(en.filter((k) => !have.includes(k)), [], `${lang} is missing keys EN defines`);
}
});
test("every message is a non-empty string, or a plural map carrying `other`", () => {
// A plural map without "other" is a message that renders as `undefined` for
// whichever count the language routes there — and which count that is depends
// on the LANGUAGE, so it can be invisible in English and broken in Romanian.
for (const lang of LANGS) {
for (const k of keysOf(lang)) {
const v = L.catalogValue(lang, k);
if (typeof v === "string") {
assert.ok(v.length > 0, `${lang}/${k} is empty`);
} else {
assert.equal(typeof v, "object", `${lang}/${k} is neither a string nor a map`);
assert.ok(typeof v.other === "string" && v.other.length > 0, `${lang}/${k} has no "other"`);
for (const [cat, text] of Object.entries(v)) {
assert.ok(["zero", "one", "two", "few", "many", "other"].includes(cat), `${lang}/${k}: ${cat}`);
assert.ok(typeof text === "string" && text.length > 0, `${lang}/${k}/${cat}`);
}
}
}
}
});
test("a plural key is plural in every language, and its slots survive translation", () => {
// A key EN made plural and RO left as a string renders one variant for every
// count in Romanian — which is exactly the language that needs three.
for (const k of keysOf("en")) {
const shape = (lang) => (typeof L.catalogValue(lang, k) === "string" ? "string" : "plural");
for (const lang of LANGS) assert.equal(shape(lang), shape("en"), `${k} in ${lang}`);
}
// …and every {slot} EN uses must exist in the translations, or the sentence
// arrives with a number missing from it
const slots = (v) => {
const text = typeof v === "string" ? v : Object.values(v).join(" ");
return new Set([...text.matchAll(/\{(\w+)\}/g)].map((m) => m[1]));
};
for (const k of keysOf("en")) {
const want = slots(L.catalogValue("en", k));
for (const lang of LANGS.slice(1)) {
const have = slots(L.catalogValue(lang, k));
assert.deepEqual([...want].filter((s) => !have.has(s)), [], `${lang}/${k} drops a slot`);
assert.deepEqual([...have].filter((s) => !want.has(s)), [], `${lang}/${k} invents a slot`);
}
}
});
test("`settled` is not a word this app says, in any language", () => {
// The fold's middle status means money moved and NOTHING HAS PINNED IT.
// docs/PROTOCOL.md §11: an interface that renders provisional and final the
// same way is lying to its user — and the shortest path to that lie is
// borrowing the fold's own vocabulary for a screen.
for (const lang of LANGS) {
for (const k of keysOf(lang)) {
const v = L.catalogValue(lang, k);
const text = (typeof v === "string" ? v : Object.values(v).join(" ")).toLowerCase();
assert.equal(text.includes("settled"), false, `${lang}/${k}: "${text}"`);
}
}
// and the words that replaced it really are there, in all three. There are
// TWO of them: the fold's one `settled` covers a payment an ack will pin and
// an issuance nothing will, and one word for both promised an
// acknowledgement that no interface can produce for a mint.
for (const lang of LANGS) {
for (const s of ["provisional", "unpinned", "final", "failed"]) {
assert.ok(L.catalogValue(lang, `pay.state.${s}`), `${lang}/${s}`);
}
// the four states must not collapse into fewer words
const words = ["provisional", "unpinned", "final", "failed"].map((s) => L.catalogValue(lang, `pay.state.${s}`));
assert.equal(new Set(words).size, 4, `${lang}: ${words.join("/")}`);
}
});
test("the sentences that carry the trust model are not empty ceremony", () => {
// Four screens have to SAY the thing rather than imply it. These assertions
// are deliberately about length and presence rather than wording: the wording
// is reviewed by a human, the presence is not.
const atLeast = {
"wallet.provisional.warn": 60,
// the issuance half of the balance card. Without it a wallet holding
// nothing but a mint reads as a number with no caveat beside it, and the
// fact that only the bank could displace it lives in the spec alone.
"wallet.unpinned.warn": 60,
"bank.ack.body": 60,
"invite.body": 60,
"bank.warn": 60,
"found.code.hint": 60,
// a chip's note sits beside a one-word state and has to stay short — but it
// must still be a SENTENCE saying what the word means, not a repeat of it.
// `unpinned` carries TWO sentences, routed by kind (view/state-note-key):
// the mint's may promise a wri-signing ack, the burn's must promise nothing
"pay.state.provisional.note": 25,
"pay.state.unpinned.note.mint": 25,
"pay.state.unpinned.note.burn": 25,
"pay.state.final.note": 25,
};
for (const lang of LANGS) {
for (const [k, min] of Object.entries(atLeast)) {
const v = L.catalogValue(lang, k);
assert.equal(typeof v, "string", `${lang}/${k}`);
assert.ok(v.length > min, `${lang}/${k} is too short to be saying anything: "${v}"`);
if (k.endsWith(".note")) {
assert.notEqual(v, L.catalogValue(lang, k.slice(0, -".note".length)),
`${lang}/${k} just repeats the word it is supposed to explain`);
}
}
}
// the reserved code has to be NAMED before the founder submits, not after
for (const lang of LANGS) {
assert.ok(L.catalogValue(lang, "found.code.hint").includes("GAZ"), lang);
assert.ok(L.catalogValue(lang, "err.charter.code-reserved").includes("GAZ"), lang);
}
});
test("an unknown language and an unknown key answer nothing, rather than something", () => {
assert.deepEqual([...L.catalogKeys("de")], []);
assert.deepEqual([...L.catalogKeys(null)], []);
assert.equal(L.catalogValue("en", "nope.not.a.key"), null);
assert.equal(L.catalogValue("de", "pay.state.final"), null);
});
|