@cruba/crubal 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +69 -2
- package/dist/bin/crubal.d.ts +2 -0
- package/dist/bin/crubal.js +37 -0
- package/dist/bin/crubal.js.map +1 -0
- package/dist/src/browser.d.ts +10 -0
- package/dist/src/browser.js +13 -0
- package/dist/src/browser.js.map +1 -0
- package/dist/src/canonical.d.ts +6 -0
- package/dist/src/canonical.js +47 -0
- package/dist/src/canonical.js.map +1 -0
- package/dist/src/conditions.d.ts +22 -0
- package/dist/src/conditions.js +175 -0
- package/dist/src/conditions.js.map +1 -0
- package/dist/src/corpus.d.ts +8 -0
- package/dist/src/corpus.js +673 -0
- package/dist/src/corpus.js.map +1 -0
- package/dist/src/defs.d.ts +116 -0
- package/dist/src/defs.js +530 -0
- package/dist/src/defs.js.map +1 -0
- package/dist/src/dictionary.d.ts +50 -0
- package/dist/src/dictionary.js +498 -0
- package/dist/src/dictionary.js.map +1 -0
- package/dist/src/exchange.d.ts +45 -0
- package/dist/src/exchange.js +321 -0
- package/dist/src/exchange.js.map +1 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +17 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lex.d.ts +21 -0
- package/dist/src/lex.js +142 -0
- package/dist/src/lex.js.map +1 -0
- package/dist/src/migrate.d.ts +88 -0
- package/dist/src/migrate.js +191 -0
- package/dist/src/migrate.js.map +1 -0
- package/dist/src/model.d.ts +36 -0
- package/dist/src/model.js +32 -0
- package/dist/src/model.js.map +1 -0
- package/dist/src/packages.d.ts +25 -0
- package/dist/src/packages.js +98 -0
- package/dist/src/packages.js.map +1 -0
- package/dist/src/print-text.d.ts +7 -0
- package/dist/src/print-text.js +194 -0
- package/dist/src/print-text.js.map +1 -0
- package/dist/src/read-text.d.ts +8 -0
- package/dist/src/read-text.js +317 -0
- package/dist/src/read-text.js.map +1 -0
- package/dist/src/rules.d.ts +64 -0
- package/dist/src/rules.js +486 -0
- package/dist/src/rules.js.map +1 -0
- package/dist/src/store.d.ts +133 -0
- package/dist/src/store.js +396 -0
- package/dist/src/store.js.map +1 -0
- package/dist/src/tree.d.ts +32 -0
- package/dist/src/tree.js +123 -0
- package/dist/src/tree.js.map +1 -0
- package/dist/src/units.d.ts +4 -0
- package/dist/src/units.js +37 -0
- package/dist/src/units.js.map +1 -0
- package/dist/src/views.d.ts +114 -0
- package/dist/src/views.js +436 -0
- package/dist/src/views.js.map +1 -0
- package/package.json +58 -4
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
// corpus.ts: runs the conformance corpus (spec chapter 13). A Reader passes a case when reading the input
|
|
2
|
+
// gives the expected canonical JSON, printing that and reading it back gives the same, and printing the
|
|
3
|
+
// expected JSON reads back to the same. An error case passes when the input is refused at the given line.
|
|
4
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { canonical } from "./canonical.js";
|
|
7
|
+
import { emptyDictionary, readDefinitions, splitCaseInput } from "./defs.js";
|
|
8
|
+
import { land, matchParts, rawOf, readContracts, receive, replay, reviewByKey, validate, verifyChain } from "./store.js";
|
|
9
|
+
import { checkRecipe, readRecipes, restamp } from "./migrate.js";
|
|
10
|
+
import { checkContracts, Exchange, readRoles, verifyBorder } from "./exchange.js";
|
|
11
|
+
import { checkPackages } from "./packages.js";
|
|
12
|
+
import { checkViews, readViews, Views } from "./views.js";
|
|
13
|
+
import { readUnder } from "./read-text.js";
|
|
14
|
+
import { readTree } from "./tree.js";
|
|
15
|
+
import { checkRules, Engine, readRules } from "./rules.js";
|
|
16
|
+
import { checkPackage, checkRecord, facetJson, foldRecord, resolve, resolvedJson } from "./dictionary.js";
|
|
17
|
+
import { serialize } from "./canonical.js";
|
|
18
|
+
import { checkWrapper } from "./model.js";
|
|
19
|
+
import { printText } from "./print-text.js";
|
|
20
|
+
import { readText } from "./read-text.js";
|
|
21
|
+
import { CrubalError } from "./tree.js";
|
|
22
|
+
const LEVEL_FOLDERS = {
|
|
23
|
+
reader: ["reader", "reader-errors"],
|
|
24
|
+
dictionary: ["reader", "reader-errors", "dictionary", "dictionary-errors"],
|
|
25
|
+
rules: ["reader", "reader-errors", "dictionary", "dictionary-errors", "rules", "rules-errors"],
|
|
26
|
+
store: ["reader", "reader-errors", "dictionary", "dictionary-errors", "rules", "rules-errors", "store", "store-errors"],
|
|
27
|
+
exchange: [
|
|
28
|
+
"reader", "reader-errors", "dictionary", "dictionary-errors", "rules", "rules-errors", "store", "store-errors",
|
|
29
|
+
"exchange", "exchange-errors", "package-errors",
|
|
30
|
+
],
|
|
31
|
+
views: [
|
|
32
|
+
"reader", "reader-errors", "dictionary", "dictionary-errors", "rules", "rules-errors", "store", "store-errors",
|
|
33
|
+
"exchange", "exchange-errors", "package-errors", "views", "views-errors",
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
/** The shared definitions a folder's cases run under: the reader package, then the folder's own files. */
|
|
37
|
+
function loadDictionary(root, folder) {
|
|
38
|
+
const dict = emptyDictionary();
|
|
39
|
+
const files = [join(root, "reader", "_package.crubal")];
|
|
40
|
+
const own = folder.replace(/-errors$/, "");
|
|
41
|
+
for (const f of ["_facets.crubal", "_classes.crubal"])
|
|
42
|
+
files.push(join(root, own, f));
|
|
43
|
+
// Data cases read under the reader's facets too; a store error case checks recipes against them.
|
|
44
|
+
if (own !== "reader" && (!folder.endsWith("-errors") || folder === "store-errors"))
|
|
45
|
+
files.splice(1, 0, join(root, "reader", "_facets.crubal"));
|
|
46
|
+
for (const p of files)
|
|
47
|
+
if (existsSync(p))
|
|
48
|
+
readDefinitions(readFileSync(p, "utf8"), dict);
|
|
49
|
+
return dict;
|
|
50
|
+
}
|
|
51
|
+
function copy(d) {
|
|
52
|
+
return { facets: new Map(d.facets), classes: new Map(d.classes), levels: new Map(d.levels), vary: new Map(d.vary), trust: [...d.trust], facetTrust: new Map(d.facetTrust) };
|
|
53
|
+
}
|
|
54
|
+
/** Each expected entry must match an actual one on every field it states; entry sets must be the same. */
|
|
55
|
+
function compareOutcomes(actual, expected) {
|
|
56
|
+
const kind = (o) => ("asked" in o ? "summary" : "value");
|
|
57
|
+
const ident = (o) => `${kind(o)}|${o.record ?? ""}|${o.address ?? ""}`;
|
|
58
|
+
const kinds = new Set(expected.map(kind));
|
|
59
|
+
const pool = actual.filter((a) => kinds.has(kind(a)));
|
|
60
|
+
const want = new Set(expected.map(ident));
|
|
61
|
+
const extra = pool.filter((a) => !want.has(ident(a)));
|
|
62
|
+
if (extra.length)
|
|
63
|
+
return `unexpected: ${extra.map((e) => serialize(e)).join(" ")}`;
|
|
64
|
+
for (const e of expected) {
|
|
65
|
+
const a = pool.find((x) => ident(x) === ident(e));
|
|
66
|
+
if (!a)
|
|
67
|
+
return `missing: ${serialize(e)}`;
|
|
68
|
+
for (const [k, v] of Object.entries(e)) {
|
|
69
|
+
if (serialize(a[k]) !== serialize(v))
|
|
70
|
+
return `${ident(e)}: ${k} is ${serialize((a[k] ?? "∅"))}, expected ${serialize(v)}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
function runDictionaryCase(folder, caseDir, name, shared) {
|
|
76
|
+
const input = readFileSync(join(caseDir, "input.crubal"), "utf8");
|
|
77
|
+
const longFile = join(caseDir, "long.crubal");
|
|
78
|
+
try {
|
|
79
|
+
if (folder === "dictionary-errors") {
|
|
80
|
+
const want = Number(readFileSync(join(caseDir, "error.txt"), "utf8").trim().split(/\s/)[0]);
|
|
81
|
+
try {
|
|
82
|
+
checkPackage(readDefinitions(input));
|
|
83
|
+
return { folder, name, pass: false, detail: "published without refusing" };
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
if (!(e instanceof CrubalError))
|
|
87
|
+
return { folder, name, pass: false, detail: String(e) };
|
|
88
|
+
return e.line === want ? { folder, name, pass: true } : { folder, name, pass: false, detail: `refused at line ${e.line} (${e.message}), expected line ${want}` };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (existsSync(longFile)) {
|
|
92
|
+
const expected = serialize(JSON.parse(readFileSync(join(caseDir, "expected.json"), "utf8")));
|
|
93
|
+
for (const [label, text] of [["shorthand", input], ["long form", readFileSync(longFile, "utf8")]]) {
|
|
94
|
+
const d = readDefinitions(text);
|
|
95
|
+
checkPackage(d);
|
|
96
|
+
const got = serialize([...d.facets.values()].filter((f) => !f.key.startsWith("crubal:")).map(facetJson));
|
|
97
|
+
if (got !== expected)
|
|
98
|
+
return { folder, name, pass: false, detail: `${label}: got ${got}` };
|
|
99
|
+
}
|
|
100
|
+
return { folder, name, pass: true };
|
|
101
|
+
}
|
|
102
|
+
const { defs, data } = splitCaseInput(input);
|
|
103
|
+
const dict = copy(shared);
|
|
104
|
+
if (defs.trim())
|
|
105
|
+
readDefinitions(defs, dict);
|
|
106
|
+
checkPackage(dict);
|
|
107
|
+
const resolveLine = defs.match(/^resolve: (\S+)/m);
|
|
108
|
+
if (resolveLine) {
|
|
109
|
+
const expected = JSON.parse(readFileSync(join(caseDir, "expected.json"), "utf8"));
|
|
110
|
+
const got = serialize(resolvedJson(resolve(dict, [resolveLine[1]])));
|
|
111
|
+
return got === serialize(expected.resolved) ? { folder, name, pass: true } : { folder, name, pass: false, detail: `got ${got}` };
|
|
112
|
+
}
|
|
113
|
+
const w = readText(data, dict);
|
|
114
|
+
w.records = w.records.map((r) => foldRecord(r, dict));
|
|
115
|
+
const outcomesFile = join(caseDir, "expected-outcomes.json");
|
|
116
|
+
if (existsSync(outcomesFile)) {
|
|
117
|
+
const actual = w.records.flatMap((r) => {
|
|
118
|
+
const c = checkRecord(r, dict);
|
|
119
|
+
return [...c.outcomes, c.summary];
|
|
120
|
+
});
|
|
121
|
+
const problem = compareOutcomes(actual, JSON.parse(readFileSync(outcomesFile, "utf8")));
|
|
122
|
+
return problem ? { folder, name, pass: false, detail: problem } : { folder, name, pass: true };
|
|
123
|
+
}
|
|
124
|
+
const expected = JSON.parse(readFileSync(join(caseDir, "expected.json"), "utf8"));
|
|
125
|
+
checkWrapper(expected);
|
|
126
|
+
const want = canonical(expected, dict);
|
|
127
|
+
const got = canonical(w, dict);
|
|
128
|
+
if (got !== want)
|
|
129
|
+
return { folder, name, pass: false, detail: `read and fold: ${firstDiff(got, want)}` };
|
|
130
|
+
const again = canonical(readText(printText(w, dict, "full"), dict), dict);
|
|
131
|
+
if (again !== want)
|
|
132
|
+
return { folder, name, pass: false, detail: `print and read back: ${firstDiff(again, want)}` };
|
|
133
|
+
return { folder, name, pass: true };
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
return { folder, name, pass: false, detail: e instanceof Error ? e.message : String(e) };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function firstDiff(a, b) {
|
|
140
|
+
let i = 0;
|
|
141
|
+
while (i < a.length && a[i] === b[i])
|
|
142
|
+
i++;
|
|
143
|
+
return `at ${i}: got …${a.slice(Math.max(0, i - 40), i + 60)}… expected …${b.slice(Math.max(0, i - 40), i + 60)}…`;
|
|
144
|
+
}
|
|
145
|
+
function runCase(folder, dir, name, dict) {
|
|
146
|
+
const caseDir = join(dir, name);
|
|
147
|
+
const input = readFileSync(join(caseDir, "input.crubal"), "utf8");
|
|
148
|
+
const errorFile = join(caseDir, "error.txt");
|
|
149
|
+
if (existsSync(errorFile)) {
|
|
150
|
+
const want = Number(readFileSync(errorFile, "utf8").trim().split(/\s/)[0]);
|
|
151
|
+
try {
|
|
152
|
+
readText(input, dict);
|
|
153
|
+
return { folder, name, pass: false, detail: "read without refusing" };
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
if (!(e instanceof CrubalError))
|
|
157
|
+
return { folder, name, pass: false, detail: String(e) };
|
|
158
|
+
return e.line === want
|
|
159
|
+
? { folder, name, pass: true }
|
|
160
|
+
: { folder, name, pass: false, detail: `refused at line ${e.line} (${e.message}), expected line ${want}` };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const expected = JSON.parse(readFileSync(join(caseDir, "expected.json"), "utf8"));
|
|
165
|
+
checkWrapper(expected);
|
|
166
|
+
const want = canonical(expected, dict);
|
|
167
|
+
const got = readText(input, dict);
|
|
168
|
+
const gotC = canonical(got, dict);
|
|
169
|
+
if (gotC !== want)
|
|
170
|
+
return { folder, name, pass: false, detail: `read: ${firstDiff(gotC, want)}` };
|
|
171
|
+
const again = canonical(readText(printText(got, dict, "full"), dict), dict);
|
|
172
|
+
if (again !== want)
|
|
173
|
+
return { folder, name, pass: false, detail: `print and read back: ${firstDiff(again, want)}` };
|
|
174
|
+
const fromJson = canonical(readText(printText(expected, dict, "full"), dict), dict);
|
|
175
|
+
if (fromJson !== want)
|
|
176
|
+
return { folder, name, pass: false, detail: `JSON to text and back: ${firstDiff(fromJson, want)}` };
|
|
177
|
+
return { folder, name, pass: true };
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
return { folder, name, pass: false, detail: e instanceof Error ? e.message : String(e) };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function compareResults(actual, expected) {
|
|
184
|
+
const ident = (o) => `${o.result}|${o.record}|${o.address ?? ""}`;
|
|
185
|
+
const kinds = new Set(expected.map((e) => e.result));
|
|
186
|
+
const pool = actual.filter((a) => kinds.has(a.result));
|
|
187
|
+
const want = new Set(expected.map(ident));
|
|
188
|
+
const extra = pool.filter((a) => !want.has(ident(a)));
|
|
189
|
+
if (extra.length)
|
|
190
|
+
return `unexpected: ${extra.map((e) => serialize(e)).join(" ")}`;
|
|
191
|
+
for (const e of expected) {
|
|
192
|
+
const a = pool.find((x) => ident(x) === ident(e));
|
|
193
|
+
if (!a)
|
|
194
|
+
return `missing: ${serialize(e)}`;
|
|
195
|
+
for (const [k, v] of Object.entries(e)) {
|
|
196
|
+
if (serialize(a[k]) !== serialize(v))
|
|
197
|
+
return `${ident(e)}: ${k} is ${serialize((a[k] ?? "∅"))}, expected ${serialize(v)}`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
function runRulesCase(folder, caseDir, name, shared, sharedRules) {
|
|
203
|
+
const input = readFileSync(join(caseDir, "input.crubal"), "utf8");
|
|
204
|
+
try {
|
|
205
|
+
if (folder === "rules-errors") {
|
|
206
|
+
const want = Number(readFileSync(join(caseDir, "error.txt"), "utf8").trim().split(/\s/)[0]);
|
|
207
|
+
try {
|
|
208
|
+
const d = copy(shared);
|
|
209
|
+
readDefinitions(input, d);
|
|
210
|
+
checkPackage(d);
|
|
211
|
+
checkRules(readRules(input), d);
|
|
212
|
+
return { folder, name, pass: false, detail: "published without refusing" };
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
if (!(e instanceof CrubalError))
|
|
216
|
+
return { folder, name, pass: false, detail: String(e) };
|
|
217
|
+
return e.line === want ? { folder, name, pass: true } : { folder, name, pass: false, detail: `refused at line ${e.line} (${e.message}), expected line ${want}` };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const { defs, data } = splitCaseInput(input);
|
|
221
|
+
const dict = copy(shared);
|
|
222
|
+
if (defs.trim())
|
|
223
|
+
readDefinitions(defs, dict);
|
|
224
|
+
const rules = [...sharedRules, ...readRules(defs)];
|
|
225
|
+
checkPackage(dict);
|
|
226
|
+
checkRules(rules, dict);
|
|
227
|
+
const engine = new Engine(dict, rules);
|
|
228
|
+
const altFile = join(caseDir, "alternatives.json");
|
|
229
|
+
const alts = existsSync(altFile) ? JSON.parse(readFileSync(altFile, "utf8")) : {};
|
|
230
|
+
const last = new Map();
|
|
231
|
+
const run = (text) => {
|
|
232
|
+
for (const r of readText(text, dict).records.map((x) => foldRecord(x, dict))) {
|
|
233
|
+
if (alts[r.id])
|
|
234
|
+
r.alternatives = alts[r.id];
|
|
235
|
+
last.set(r.id, engine.write(r));
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
run(data);
|
|
239
|
+
const writeFile = join(caseDir, "write.crubal");
|
|
240
|
+
if (existsSync(writeFile))
|
|
241
|
+
run(readFileSync(writeFile, "utf8"));
|
|
242
|
+
const actual = [...last.values()].flat();
|
|
243
|
+
const problem = compareResults(actual, JSON.parse(readFileSync(join(caseDir, "expected-results.json"), "utf8")));
|
|
244
|
+
return problem ? { folder, name, pass: false, detail: problem } : { folder, name, pass: true };
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
return { folder, name, pass: false, detail: e instanceof Error ? e.message : String(e) };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** Every field an expected object states must be equal in the actual one. */
|
|
251
|
+
function subset(actual, expected, label = "") {
|
|
252
|
+
for (const [k, v] of Object.entries(expected)) {
|
|
253
|
+
if (serialize(actual[k]) !== serialize(v))
|
|
254
|
+
return `${label}${k} is ${serialize((actual[k] ?? "∅"))}, expected ${serialize(v)}`;
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
function runStoreCase(folder, caseDir, name, shared) {
|
|
259
|
+
const file = (f) => join(caseDir, f);
|
|
260
|
+
const json = (f) => JSON.parse(readFileSync(file(f), "utf8"));
|
|
261
|
+
const pass = { folder, name, pass: true };
|
|
262
|
+
const fail = (detail) => ({ folder, name, pass: false, detail });
|
|
263
|
+
try {
|
|
264
|
+
const input = existsSync(file("input.crubal")) ? readFileSync(file("input.crubal"), "utf8") : "";
|
|
265
|
+
if (folder === "store-errors") {
|
|
266
|
+
const want = Number(readFileSync(file("error.txt"), "utf8").trim().split(/\s/)[0]);
|
|
267
|
+
try {
|
|
268
|
+
for (const r of readRecipes(input))
|
|
269
|
+
checkRecipe(r, shared);
|
|
270
|
+
return fail("published without refusing");
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
if (!(e instanceof CrubalError))
|
|
274
|
+
return fail(String(e));
|
|
275
|
+
return e.line === want ? pass : fail(`refused at line ${e.line} (${e.message}), expected line ${want}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (existsSync(file("log.json"))) {
|
|
279
|
+
const keys = existsSync(file("keys.json")) ? json("keys.json") : undefined;
|
|
280
|
+
const got = verifyChain(json("log.json"), keys);
|
|
281
|
+
const want = json("expected-verify.json");
|
|
282
|
+
const p = subset(got, want);
|
|
283
|
+
return p ? fail(`${p} (got ${serialize(got)})`) : pass;
|
|
284
|
+
}
|
|
285
|
+
if (existsSync(file("answer.bin"))) {
|
|
286
|
+
const got = rawOf(readFileSync(file("answer.bin")), "text/plain");
|
|
287
|
+
return serialize(got) === serialize(json("expected-raw.json")) ? pass : fail(`got ${serialize(got)}`);
|
|
288
|
+
}
|
|
289
|
+
const { defs, data } = splitCaseInput(input);
|
|
290
|
+
const dict = copy(shared);
|
|
291
|
+
if (defs.trim())
|
|
292
|
+
readDefinitions(defs, dict);
|
|
293
|
+
const records = readText(data, dict).records.map((r) => foldRecord(r, dict));
|
|
294
|
+
const one = (r) => canonical({ crubal: "2", records: [r] }, dict);
|
|
295
|
+
if (existsSync(file("recipe.crubal"))) {
|
|
296
|
+
const [recipe] = readRecipes(readFileSync(file("recipe.crubal"), "utf8"));
|
|
297
|
+
if (!recipe)
|
|
298
|
+
return fail("no recipe");
|
|
299
|
+
checkRecipe(recipe, dict);
|
|
300
|
+
const { records: out, review } = restamp(records, recipe, dict);
|
|
301
|
+
const want = json("expected.json");
|
|
302
|
+
const got = { ...JSON.parse(canonical({ crubal: "2", package: recipe.to, records: out }, dict)), review };
|
|
303
|
+
const wantC = { ...JSON.parse(canonical({ crubal: String(want.crubal), package: String(want.package), records: want.records }, dict)), review: want.review };
|
|
304
|
+
return serialize(got) === serialize(wantC) ? pass : fail(`got ${firstDiff(serialize(got), serialize(wantC))}`);
|
|
305
|
+
}
|
|
306
|
+
if (existsSync(file("contract-v2.crubal"))) {
|
|
307
|
+
const [v1] = readContracts(readFileSync(file("contract.crubal"), "utf8"));
|
|
308
|
+
const [v2] = readContracts(readFileSync(file("contract-v2.crubal"), "utf8"));
|
|
309
|
+
if (!v1 || !v2)
|
|
310
|
+
return fail("no contract");
|
|
311
|
+
const body = readFileSync(file("answer.json"), "utf8");
|
|
312
|
+
const first = receive(v1, body, records[0]?.id ?? "", dict);
|
|
313
|
+
const again = replay(first, v2, dict);
|
|
314
|
+
const twice = replay(first, v2, dict);
|
|
315
|
+
const want = json("expected-replay.json");
|
|
316
|
+
const replayed = (again.replays.at(-1)?.outcomes ?? []);
|
|
317
|
+
const check = (got, exp, label) => {
|
|
318
|
+
for (const w of exp) {
|
|
319
|
+
const a = got.find((o) => o.from === w.from);
|
|
320
|
+
if (!a)
|
|
321
|
+
return `${label}: no outcome for ${w.from}`;
|
|
322
|
+
const p = subset(a, w, `${label} ${w.from}: `);
|
|
323
|
+
if (p)
|
|
324
|
+
return p;
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
};
|
|
328
|
+
const p = check(first.outcomes, want.first, "first") ?? check(replayed, want.replayed, "replayed");
|
|
329
|
+
if (p)
|
|
330
|
+
return fail(p);
|
|
331
|
+
if (again.replays.length !== want.replays)
|
|
332
|
+
return fail(`${again.replays.length} replays`);
|
|
333
|
+
if (serialize(again.replays) !== serialize(twice.replays))
|
|
334
|
+
return fail("replay isn't deterministic");
|
|
335
|
+
if (again.raw.body !== body || again.raw.sha256 !== first.raw.sha256)
|
|
336
|
+
return fail("replay changed the kept bytes");
|
|
337
|
+
return pass;
|
|
338
|
+
}
|
|
339
|
+
if (existsSync(file("expected-review.json"))) {
|
|
340
|
+
const got = reviewByKey(json("intakes.json"));
|
|
341
|
+
const want = json("expected-review.json");
|
|
342
|
+
if (got["by-key"].length !== want["by-key"].length)
|
|
343
|
+
return fail(`by-key: got ${serialize(got["by-key"])}`);
|
|
344
|
+
for (let i = 0; i < want["by-key"].length; i++) {
|
|
345
|
+
const p = subset(got["by-key"][i], want["by-key"][i], `by-key[${i}] `);
|
|
346
|
+
if (p)
|
|
347
|
+
return fail(p);
|
|
348
|
+
}
|
|
349
|
+
return serialize(got.proposals) === serialize(want.proposals) ? pass : fail(`proposals: got ${serialize(got.proposals)}`);
|
|
350
|
+
}
|
|
351
|
+
if (existsSync(file("contract.crubal"))) {
|
|
352
|
+
const text = readFileSync(file("contract.crubal"), "utf8");
|
|
353
|
+
readDefinitions(text, dict);
|
|
354
|
+
const [contract] = readContracts(text);
|
|
355
|
+
if (!contract)
|
|
356
|
+
return fail("no contract");
|
|
357
|
+
const intake = receive(contract, readFileSync(file("answer.json"), "utf8"), records[0]?.id ?? "", dict);
|
|
358
|
+
const want = json("expected-intake.json").outcomes;
|
|
359
|
+
if (intake.outcomes.length !== want.length)
|
|
360
|
+
return fail(`got ${intake.outcomes.length} outcomes: ${serialize(intake.outcomes)}`);
|
|
361
|
+
for (const w of want) {
|
|
362
|
+
const a = intake.outcomes.find((o) => o.from === w.from);
|
|
363
|
+
if (!a)
|
|
364
|
+
return fail(`no outcome for ${w.from}`);
|
|
365
|
+
const p = subset(a, w, `${w.from}: `);
|
|
366
|
+
if (p)
|
|
367
|
+
return fail(p);
|
|
368
|
+
}
|
|
369
|
+
return pass;
|
|
370
|
+
}
|
|
371
|
+
if (existsSync(file("answer.json"))) {
|
|
372
|
+
const answer = json("answer.json");
|
|
373
|
+
const got = matchParts(records[0], answer.parts, dict);
|
|
374
|
+
const want = json("expected.json");
|
|
375
|
+
return serialize(got) === serialize(want) ? pass : fail(`got ${serialize(got)}`);
|
|
376
|
+
}
|
|
377
|
+
if (existsSync(file("observations.json"))) {
|
|
378
|
+
const want = json("expected-validation.json");
|
|
379
|
+
const rec = records.find((r) => r.id === want.record);
|
|
380
|
+
const got = validate(rec, String(want.address), json("observations.json"));
|
|
381
|
+
const p = subset(got, want);
|
|
382
|
+
return p ? fail(p) : pass;
|
|
383
|
+
}
|
|
384
|
+
const intakes = existsSync(file("intakes.json")) ? json("intakes.json") : [json("intake.json")];
|
|
385
|
+
let rec = records[0];
|
|
386
|
+
for (const i of intakes)
|
|
387
|
+
rec = land(rec, i, { origin: "reported", via: String(i.log) }, dict.trust, dict.facetTrust);
|
|
388
|
+
const want = json("expected.json");
|
|
389
|
+
const got = one(rec);
|
|
390
|
+
const wantC = one(want);
|
|
391
|
+
const alts = serialize((rec.alternatives ?? {})) === serialize((want.alternatives ?? {}));
|
|
392
|
+
return got === wantC && alts ? pass : fail(`got ${firstDiff(got, wantC)} alternatives ${serialize((rec.alternatives ?? {}))}`);
|
|
393
|
+
}
|
|
394
|
+
catch (e) {
|
|
395
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function runExchangeCase(folder, caseDir, name, shared, root) {
|
|
399
|
+
const file = (f) => join(caseDir, f);
|
|
400
|
+
const json = (f) => JSON.parse(readFileSync(file(f), "utf8"));
|
|
401
|
+
const pass = { folder, name, pass: true };
|
|
402
|
+
const fail = (detail) => ({ folder, name, pass: false, detail });
|
|
403
|
+
try {
|
|
404
|
+
const input = existsSync(file("input.crubal")) ? readFileSync(file("input.crubal"), "utf8") : "";
|
|
405
|
+
if (folder === "exchange-errors" || folder === "package-errors") {
|
|
406
|
+
const want = Number(readFileSync(file("error.txt"), "utf8").trim().split(/\s/)[0]);
|
|
407
|
+
try {
|
|
408
|
+
if (folder === "package-errors")
|
|
409
|
+
checkPackages(input);
|
|
410
|
+
else {
|
|
411
|
+
const d = copy(shared);
|
|
412
|
+
readDefinitions(input, d);
|
|
413
|
+
checkPackage(d);
|
|
414
|
+
checkContracts(readContracts(input), d);
|
|
415
|
+
}
|
|
416
|
+
return fail("published without refusing");
|
|
417
|
+
}
|
|
418
|
+
catch (e) {
|
|
419
|
+
if (!(e instanceof CrubalError))
|
|
420
|
+
return fail(String(e));
|
|
421
|
+
return e.line === want ? pass : fail(`refused at line ${e.line} (${e.message}), expected line ${want}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (existsSync(file("border.json"))) {
|
|
425
|
+
const got = verifyBorder(json("border.json"), json("keys.json"));
|
|
426
|
+
const p = subset(got, json("expected-border.json"));
|
|
427
|
+
return p ? fail(`${p} (got ${serialize(got)})`) : pass;
|
|
428
|
+
}
|
|
429
|
+
const { defs, data } = splitCaseInput(input);
|
|
430
|
+
const dict = copy(shared);
|
|
431
|
+
if (defs.trim())
|
|
432
|
+
readDefinitions(defs, dict);
|
|
433
|
+
checkPackage(dict);
|
|
434
|
+
const shared_ = readFileSync(join(root, "exchange", "_contracts.crubal"), "utf8");
|
|
435
|
+
const contracts = [...readContracts(shared_), ...readContracts(defs)];
|
|
436
|
+
checkContracts(contracts, dict);
|
|
437
|
+
const ex = new Exchange(dict, contracts, readRoles(defs));
|
|
438
|
+
for (const r of readText(data, dict).records.map((x) => foldRecord(x, dict)))
|
|
439
|
+
ex.put(r);
|
|
440
|
+
// The runs, in order.
|
|
441
|
+
const results = [];
|
|
442
|
+
let current = {};
|
|
443
|
+
const flush = () => {
|
|
444
|
+
if (!current.run || !current.on)
|
|
445
|
+
return;
|
|
446
|
+
const c = contracts.find((x) => x.name === current.run);
|
|
447
|
+
if (current.offer) {
|
|
448
|
+
results.push(ex.take(current.run, current.offer, current.on, json("presenter.json")));
|
|
449
|
+
}
|
|
450
|
+
else if (c?.kind === "receive") {
|
|
451
|
+
results.push(ex.receiveRun(current.run, current.on, readFileSync(file("answer.json"), "utf8")));
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
results.push(ex.run(current.run, current.on, current.caller));
|
|
455
|
+
}
|
|
456
|
+
current = {};
|
|
457
|
+
};
|
|
458
|
+
for (const n of readTree(readFileSync(file("run.crubal"), "utf8"))) {
|
|
459
|
+
if (n.key === "run") {
|
|
460
|
+
flush();
|
|
461
|
+
current.run = n.value;
|
|
462
|
+
}
|
|
463
|
+
else if (n.key === "on")
|
|
464
|
+
current.on = n.value;
|
|
465
|
+
else if (n.key === "caller")
|
|
466
|
+
current.caller = n.value;
|
|
467
|
+
else if (n.key === "offer")
|
|
468
|
+
current.offer = n.value;
|
|
469
|
+
else if (n.key === "write") {
|
|
470
|
+
flush();
|
|
471
|
+
const r = ex.get(n.value);
|
|
472
|
+
if (!r)
|
|
473
|
+
return fail(`write to an unknown record ${n.value}`);
|
|
474
|
+
const values = { ...r.values };
|
|
475
|
+
for (const c of n.children) {
|
|
476
|
+
const f = c.key ? dict.facets.get(c.key) : undefined;
|
|
477
|
+
if (c.key)
|
|
478
|
+
values[c.key] = f ? (readUnder(f, c.value, dict) ?? c.value) : c.value;
|
|
479
|
+
}
|
|
480
|
+
ex.put({ ...r, values });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
flush();
|
|
484
|
+
if (existsSync(file("expected.json"))) {
|
|
485
|
+
const id = json("expected.json").id;
|
|
486
|
+
const got = ex.get(id);
|
|
487
|
+
const want = json("expected.json");
|
|
488
|
+
const one = (r) => canonical({ crubal: "2", records: [r] }, dict);
|
|
489
|
+
const extra = (r) => serialize({ a: (r.alternatives ?? {}), s: (r.suggestions ?? {}) });
|
|
490
|
+
if (one(got) !== one(want))
|
|
491
|
+
return fail(`record: ${firstDiff(one(got), one(want))}`);
|
|
492
|
+
if (extra(got) !== extra(want))
|
|
493
|
+
return fail(`alternatives/suggestions: got ${extra(got)}`);
|
|
494
|
+
return pass;
|
|
495
|
+
}
|
|
496
|
+
const wantRaw = json("expected-run.json");
|
|
497
|
+
const want = (Array.isArray(wantRaw) ? wantRaw : [wantRaw]);
|
|
498
|
+
if (results.length !== want.length)
|
|
499
|
+
return fail(`${results.length} runs, expected ${want.length}`);
|
|
500
|
+
for (let i = 0; i < want.length; i++) {
|
|
501
|
+
const w = { ...want[i] };
|
|
502
|
+
const a = results[i];
|
|
503
|
+
if (Array.isArray(w.outcomes)) {
|
|
504
|
+
for (const wo of w.outcomes) {
|
|
505
|
+
const ao = a.outcomes.find((x) => x.from === wo.from);
|
|
506
|
+
if (!ao)
|
|
507
|
+
return fail(`run ${i + 1}: no outcome for ${wo.from}`);
|
|
508
|
+
const p = subset(ao, wo, `run ${i + 1} ${wo.from}: `);
|
|
509
|
+
if (p)
|
|
510
|
+
return fail(p);
|
|
511
|
+
}
|
|
512
|
+
if (a.outcomes.length !== w.outcomes.length)
|
|
513
|
+
return fail(`run ${i + 1}: ${a.outcomes.length} outcomes`);
|
|
514
|
+
delete w.outcomes;
|
|
515
|
+
}
|
|
516
|
+
if (isPlainObject(w.sources)) {
|
|
517
|
+
const p = subset(a.sources, w.sources, `run ${i + 1} sources `);
|
|
518
|
+
if (p)
|
|
519
|
+
return fail(p);
|
|
520
|
+
delete w.sources;
|
|
521
|
+
}
|
|
522
|
+
// Stopping is the verdict, not a detail: an expectation must say so when a run stopped.
|
|
523
|
+
if (a.stopped !== undefined && w.stopped === undefined)
|
|
524
|
+
return fail(`run ${i + 1}: stopped at ${serialize(a.stopped)}, expected it to run`);
|
|
525
|
+
const p = subset(a, w, `run ${i + 1}: `);
|
|
526
|
+
if (p)
|
|
527
|
+
return fail(p);
|
|
528
|
+
}
|
|
529
|
+
return pass;
|
|
530
|
+
}
|
|
531
|
+
catch (e) {
|
|
532
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const RUN_KEYS = new Set(["pick", "context", "audiences", "callers", "page"]);
|
|
536
|
+
function runViewsCase(folder, caseDir, name, shared, root) {
|
|
537
|
+
const file = (f) => join(caseDir, f);
|
|
538
|
+
const json = (f) => JSON.parse(readFileSync(file(f), "utf8"));
|
|
539
|
+
const pass = { folder, name, pass: true };
|
|
540
|
+
const fail = (detail) => ({ folder, name, pass: false, detail });
|
|
541
|
+
try {
|
|
542
|
+
const input = readFileSync(file("input.crubal"), "utf8");
|
|
543
|
+
if (folder === "views-errors") {
|
|
544
|
+
const want = Number(readFileSync(file("error.txt"), "utf8").trim().split(/\s/)[0]);
|
|
545
|
+
try {
|
|
546
|
+
const d = copy(shared);
|
|
547
|
+
readDefinitions(input, d);
|
|
548
|
+
checkPackage(d);
|
|
549
|
+
checkViews(readViews(input), d);
|
|
550
|
+
return fail("published without refusing");
|
|
551
|
+
}
|
|
552
|
+
catch (e) {
|
|
553
|
+
if (!(e instanceof CrubalError))
|
|
554
|
+
return fail(String(e));
|
|
555
|
+
return e.line === want ? pass : fail(`refused at line ${e.line} (${e.message}), expected line ${want}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// Run lines (a bodiless "view:", pick, context, audiences, callers, page) are instructions, not definitions.
|
|
559
|
+
const lines = input.split("\n");
|
|
560
|
+
const run = { picks: {} };
|
|
561
|
+
const tree = readTree(input);
|
|
562
|
+
for (const n of tree) {
|
|
563
|
+
const isRun = (n.key === "view" && n.children.length === 0) || (n.key !== null && RUN_KEYS.has(n.key));
|
|
564
|
+
if (!isRun)
|
|
565
|
+
continue;
|
|
566
|
+
const blank = (x) => {
|
|
567
|
+
lines[x.line - 1] = "";
|
|
568
|
+
for (const c of x.children)
|
|
569
|
+
blank(c);
|
|
570
|
+
};
|
|
571
|
+
blank(n);
|
|
572
|
+
if (n.key === "view")
|
|
573
|
+
run.view = n.value;
|
|
574
|
+
if (n.key === "pick")
|
|
575
|
+
for (const c of n.children)
|
|
576
|
+
if (c.key)
|
|
577
|
+
run.picks[c.key] = c.value.split(",").map((x) => x.trim());
|
|
578
|
+
if (n.key === "context")
|
|
579
|
+
run.context = n.value;
|
|
580
|
+
if (n.key === "audiences")
|
|
581
|
+
run.audiences = n.value.split(",").map((x) => x.trim());
|
|
582
|
+
if (n.key === "callers")
|
|
583
|
+
run.callers = n.value.split(",").map((x) => x.trim());
|
|
584
|
+
if (n.key === "page")
|
|
585
|
+
run.page = Number(n.value);
|
|
586
|
+
}
|
|
587
|
+
const { defs, data } = splitCaseInput(lines.join("\n"));
|
|
588
|
+
const dict = copy(shared);
|
|
589
|
+
const sharedViews = readFileSync(join(root, "views", "_views.crubal"), "utf8");
|
|
590
|
+
readDefinitions(sharedViews, dict);
|
|
591
|
+
if (defs.trim())
|
|
592
|
+
readDefinitions(defs, dict);
|
|
593
|
+
checkPackage(dict);
|
|
594
|
+
const set = readViews(sharedViews);
|
|
595
|
+
const own = readViews(defs);
|
|
596
|
+
for (const k of ["shapes", "filters", "views", "roles", "shown"])
|
|
597
|
+
for (const [key, val] of own[k])
|
|
598
|
+
set[k].set(key, val);
|
|
599
|
+
checkViews(set, dict);
|
|
600
|
+
const views = new Views(dict, set);
|
|
601
|
+
const text = data.trim() ? data : readFileSync(join(root, "views", "_records.crubal"), "utf8");
|
|
602
|
+
const records = readText(text, dict).records.map((r) => foldRecord(r, dict));
|
|
603
|
+
for (const r of records)
|
|
604
|
+
views.put(r);
|
|
605
|
+
const byId = new Map(records.map((r) => [r.id, r]));
|
|
606
|
+
const viewName = run.view;
|
|
607
|
+
const opts = { picks: run.picks, ...(run.context ? { context: byId.get(run.context) } : {}), ...(run.page ? { page: run.page } : {}) };
|
|
608
|
+
const same = (got, want, label) => (serialize(got) === serialize(want) ? pass : fail(`${label}: got ${serialize(got)}`));
|
|
609
|
+
if (existsSync(file("expected-stale.json"))) {
|
|
610
|
+
views.project(viewName, opts);
|
|
611
|
+
const stale = new Set();
|
|
612
|
+
for (const w of readText(readFileSync(file("write.crubal"), "utf8"), dict).records)
|
|
613
|
+
for (const id of views.staleAfter(viewName, w))
|
|
614
|
+
stale.add(id);
|
|
615
|
+
const want = json("expected-stale.json");
|
|
616
|
+
const wrong = [...want.stale.filter((id) => !stale.has(id)), ...want["not-stale"].filter((id) => stale.has(id))];
|
|
617
|
+
return wrong.length ? fail(`stale: got ${[...stale].join(", ")}`) : pass;
|
|
618
|
+
}
|
|
619
|
+
if (existsSync(file("expected-by.json"))) {
|
|
620
|
+
const got = {};
|
|
621
|
+
for (const a of run.audiences ?? [])
|
|
622
|
+
got[a] = views.project(viewName, { ...opts, audience: a }).rows;
|
|
623
|
+
for (const c of run.callers ?? [])
|
|
624
|
+
got[c] = views.project(viewName, { ...opts, caller: byId.get(c) }).rows;
|
|
625
|
+
return same(got, json("expected-by.json"), "by audience");
|
|
626
|
+
}
|
|
627
|
+
const p = views.project(viewName, opts);
|
|
628
|
+
if (existsSync(file("expected-counts.json")))
|
|
629
|
+
return same({ records: p.records, counts: p.counts, indexable: p.indexable }, json("expected-counts.json"), "counts");
|
|
630
|
+
if (existsSync(file("expected-pages.json")))
|
|
631
|
+
return same({ pages: p.pages, records: p.records, indexable: p.indexable }, json("expected-pages.json"), "pages");
|
|
632
|
+
const wantFile = existsSync(file("expected-portable.json")) ? "expected-portable.json" : "expected-projection.json";
|
|
633
|
+
return same(p.rows, json(wantFile), "projection");
|
|
634
|
+
}
|
|
635
|
+
catch (e) {
|
|
636
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
640
|
+
/** Run one level's corpus folders under `root`. */
|
|
641
|
+
export function runCorpus(root, level) {
|
|
642
|
+
const folders = LEVEL_FOLDERS[level];
|
|
643
|
+
if (!folders)
|
|
644
|
+
throw new Error(`no corpus runner for level ${level} yet`);
|
|
645
|
+
const results = [];
|
|
646
|
+
for (const folder of folders) {
|
|
647
|
+
const dir = join(root, folder);
|
|
648
|
+
const dict = loadDictionary(root, folder);
|
|
649
|
+
const rulesFile = join(root, "rules", "_rules.crubal");
|
|
650
|
+
const sharedRules = folder === "rules" && existsSync(rulesFile) ? readRules(readFileSync(rulesFile, "utf8")) : [];
|
|
651
|
+
for (const name of readdirSync(dir).sort()) {
|
|
652
|
+
if (name.startsWith("_"))
|
|
653
|
+
continue;
|
|
654
|
+
if (!["input.crubal", "log.json", "border.json"].some((f) => existsSync(join(dir, name, f))))
|
|
655
|
+
continue;
|
|
656
|
+
const r = folder.startsWith("views")
|
|
657
|
+
? runViewsCase(folder, join(dir, name), name, dict, root)
|
|
658
|
+
: folder.startsWith("exchange") || folder === "package-errors"
|
|
659
|
+
? runExchangeCase(folder, join(dir, name), name, dict, root)
|
|
660
|
+
: folder.startsWith("store")
|
|
661
|
+
? runStoreCase(folder, join(dir, name), name, dict)
|
|
662
|
+
: folder.startsWith("rules")
|
|
663
|
+
? runRulesCase(folder, join(dir, name), name, dict, sharedRules)
|
|
664
|
+
: folder.startsWith("dictionary")
|
|
665
|
+
? runDictionaryCase(folder, join(dir, name), name, dict)
|
|
666
|
+
: runCase(folder, dir, name, dict);
|
|
667
|
+
if (r)
|
|
668
|
+
results.push(r);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return results;
|
|
672
|
+
}
|
|
673
|
+
//# sourceMappingURL=corpus.js.map
|