@crossworks/content-core 0.230.43
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.md +135 -0
- package/package.json +41 -0
- package/src/block-diff.test.ts +190 -0
- package/src/block-diff.ts +163 -0
- package/src/block-ids.test.ts +358 -0
- package/src/block-ids.ts +242 -0
- package/src/block-list.test.ts +241 -0
- package/src/block-list.ts +177 -0
- package/src/contacts-format.ts +260 -0
- package/src/doc-to-markdown.test.ts +194 -0
- package/src/doc-to-markdown.ts +315 -0
- package/src/formula-dimensions.test.ts +103 -0
- package/src/formula-dimensions.ts +231 -0
- package/src/formula-eval.ts +294 -0
- package/src/formula-seed.test.ts +175 -0
- package/src/formula-seed.ts +466 -0
- package/src/formula-signature.test.ts +336 -0
- package/src/formula-signature.ts +435 -0
- package/src/formula-spec.test.ts +458 -0
- package/src/formula-spec.ts +566 -0
- package/src/journal-options.test.ts +57 -0
- package/src/journal-options.ts +77 -0
- package/src/markdown-refs.test.ts +143 -0
- package/src/markdown-refs.ts +172 -0
- package/src/markdown-to-doc.test.ts +179 -0
- package/src/markdown-to-doc.ts +567 -0
- package/src/onboarding-questions.test.ts +75 -0
- package/src/onboarding-questions.ts +90 -0
- package/src/page-diff.test.ts +82 -0
- package/src/page-diff.ts +120 -0
- package/src/page-split.test.ts +141 -0
- package/src/page-split.ts +128 -0
- package/src/page-toc.test.ts +58 -0
- package/src/page-toc.ts +89 -0
- package/src/persona-bank.test.ts +67 -0
- package/src/persona-bank.ts +234 -0
- package/src/table-formula-mathjs.ts +259 -0
- package/src/table-formula.test.ts +157 -0
- package/src/table-formula.ts +496 -0
- package/src/table-model.test.ts +429 -0
- package/src/table-model.ts +870 -0
- package/src/thinking-tiers.ts +56 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { checkLookupCoverage, parseFormulaSpec } from './formula-spec';
|
|
3
|
+
import { evaluateSpec } from './formula-eval';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The fixture is a real model, not a toy: the release-quantity calculation from
|
|
7
|
+
* API RP 581 Part 3 (§5.3.2, §5.3.3.a, Tables 5.5–5.7). It is the reason the
|
|
8
|
+
* spec format has four element kinds, so it is what the tests are held to.
|
|
9
|
+
*/
|
|
10
|
+
const raw = {
|
|
11
|
+
id: 'api581-release-quantity',
|
|
12
|
+
name: 'Release Quantity',
|
|
13
|
+
source: { standard: 'API RP 581', part: '3', sections: ['5.3.2', '5.3.3.a'] },
|
|
14
|
+
unitSystem: 'USC',
|
|
15
|
+
variables: [
|
|
16
|
+
{ symbol: 'Cd', role: 'constant', value: 0.61, unit: null },
|
|
17
|
+
{ symbol: 'Kvn', role: 'constant', value: 1, unit: null },
|
|
18
|
+
{ symbol: 'C1', role: 'constant', value: 12, unit: 'in/ft' },
|
|
19
|
+
{ symbol: 'C2', role: 'constant', value: 1, unit: null },
|
|
20
|
+
// g_c is the gravitational CONVERSION constant, not g. Numerically
|
|
21
|
+
// identical in USC, which is exactly why the unit string must say so —
|
|
22
|
+
// read as an acceleration, an SI port substitutes 9.81 where the right
|
|
23
|
+
// value is 1.0 and every release rate is out by a factor of 3.13.
|
|
24
|
+
{ symbol: 'gc', role: 'constant', value: 32.2, unit: 'lbm-ft/(lbf-s2)' },
|
|
25
|
+
{ symbol: 'R', role: 'constant', value: 1545, unit: 'ft-lbf/(lb-mol·°R)' },
|
|
26
|
+
{ symbol: 'k', role: 'constant', value: 1.5, unit: null },
|
|
27
|
+
{ symbol: 'Patm', role: 'constant', value: 14.7, unit: 'lbf/in2 (abs)' },
|
|
28
|
+
{ symbol: 'd', role: 'input', value: 0.375, unit: 'in' },
|
|
29
|
+
{ symbol: 'An', role: 'derived', expression: 'PI / 4 * {d} ^ 2', unit: 'in2' },
|
|
30
|
+
{ symbol: 'rho_l', role: 'input', unit: 'lb/ft3' },
|
|
31
|
+
{ symbol: 'Pgauge', role: 'input', unit: 'lbf/in2 (g)' },
|
|
32
|
+
{ symbol: 'Ps', role: 'input', unit: 'lbf/in2 (abs)' },
|
|
33
|
+
{ symbol: 'MW', role: 'input', unit: 'lb/lb-mol' },
|
|
34
|
+
{ symbol: 'Ts', role: 'input', unit: 'R' },
|
|
35
|
+
{ symbol: 'ReleaseTime', role: 'input', value: 3600, unit: 'sec' },
|
|
36
|
+
{ symbol: 'Wn', role: 'output', unit: 'lb/sec' },
|
|
37
|
+
],
|
|
38
|
+
expressions: [
|
|
39
|
+
{
|
|
40
|
+
id: 'liquid-release-rate',
|
|
41
|
+
equation: '3.3',
|
|
42
|
+
resultSymbol: 'Wn',
|
|
43
|
+
expression: '{Cd} * {Kvn} * {rho_l} * ({An} / {C1}) * SQRT(2 * {gc} * {Pgauge} / {rho_l})',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'transition-pressure',
|
|
47
|
+
equation: '3.7',
|
|
48
|
+
unverified:
|
|
49
|
+
'Supplied from memory: the source references a transition pressure but never defines it. Equation number is inferred. Confirm against the standard.',
|
|
50
|
+
resultSymbol: 'Ptrans',
|
|
51
|
+
expression: '{Patm} * (({k} + 1) / 2) ^ ({k} / ({k} - 1))',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'vapor-sonic',
|
|
55
|
+
equation: '3.6',
|
|
56
|
+
expression:
|
|
57
|
+
'({Cd} / {C2}) * {An} * {Ps} * SQRT( ({k} * {MW} * {gc}) / ({R} * {Ts})' +
|
|
58
|
+
' * (2 / ({k} + 1)) ^ (({k} + 1) / ({k} - 1)) )',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'vapor-subsonic',
|
|
62
|
+
equation: '3.5',
|
|
63
|
+
expression:
|
|
64
|
+
'({Cd} / {C2}) * {An} * {Ps} * SQRT( (({MW} * {gc}) / ({R} * {Ts}))' +
|
|
65
|
+
' * ((2 * {k}) / ({k} - 1)) * ({Patm} / {Ps}) ^ (2 / {k})' +
|
|
66
|
+
' * (1 - ({Patm} / {Ps}) ^ (({k} - 1) / {k})) )',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'volume-bbl',
|
|
70
|
+
expression: '0.1781 * ({Wn} / {rho_l}) * {ReleaseTime}',
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
piecewise: [
|
|
74
|
+
{
|
|
75
|
+
id: 'vapor-release-rate',
|
|
76
|
+
resultSymbol: 'Wn',
|
|
77
|
+
cases: [
|
|
78
|
+
{ when: '{Ps} > {Ptrans}', use: 'vapor-sonic', label: 'Sonic' },
|
|
79
|
+
{ when: '{Ps} <= {Ptrans}', use: 'vapor-subsonic', label: 'Subsonic' },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
lookups: [
|
|
84
|
+
{
|
|
85
|
+
id: 'fact_di',
|
|
86
|
+
name: 'Release Magnitude Reduction Factor',
|
|
87
|
+
keys: ['detection', 'isolation'],
|
|
88
|
+
result: 'fact_di',
|
|
89
|
+
domains: { detection: ['A', 'B', 'C'], isolation: ['A', 'B', 'C'] },
|
|
90
|
+
rows: [
|
|
91
|
+
{ detection: 'A', isolation: 'A', fact_di: 0.25 },
|
|
92
|
+
{ detection: 'A', isolation: 'B', fact_di: 0.2 },
|
|
93
|
+
{ detection: 'A', isolation: 'C', fact_di: 0.1 },
|
|
94
|
+
{ detection: 'B', isolation: 'B', fact_di: 0.15 },
|
|
95
|
+
{ detection: 'B', isolation: 'C', fact_di: 0.1 },
|
|
96
|
+
{ detection: 'C', isolation: 'C', fact_di: 0.0 },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: 'ld_max',
|
|
101
|
+
name: 'Maximum Leak Duration',
|
|
102
|
+
keys: ['detection', 'isolation', 'holeSize'],
|
|
103
|
+
result: 'ld_max',
|
|
104
|
+
rows: [
|
|
105
|
+
{ detection: 'A', isolation: 'A', holeSize: '1/4 in', ld_max: 20 },
|
|
106
|
+
{ detection: 'A', isolation: 'A', holeSize: '1 in', ld_max: 10 },
|
|
107
|
+
{ detection: 'A', isolation: 'A', holeSize: '4 in', ld_max: 5 },
|
|
108
|
+
{ detection: 'B', isolation: 'C', holeSize: '1/4 in', ld_max: 60 },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
classifications: [
|
|
113
|
+
{
|
|
114
|
+
id: 'detection-rating',
|
|
115
|
+
domain: ['A', 'B', 'C'],
|
|
116
|
+
criteria: {
|
|
117
|
+
A: 'Instrumentation designed specifically to detect material losses by changes in operating conditions.',
|
|
118
|
+
B: 'Suitably located detectors to determine when the material is present outside the pressure-containing envelope.',
|
|
119
|
+
C: 'Visual detection, cameras, or detectors with marginal coverage.',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const parsed = parseFormulaSpec(raw);
|
|
126
|
+
if (!parsed.ok) throw new Error(`fixture failed to parse: ${parsed.errors.join('; ')}`);
|
|
127
|
+
const spec = parsed.spec;
|
|
128
|
+
|
|
129
|
+
describe('parseFormulaSpec', () => {
|
|
130
|
+
it('accepts the reference spec', () => {
|
|
131
|
+
expect(parsed.ok).toBe(true);
|
|
132
|
+
expect(spec.expressions).toHaveLength(5);
|
|
133
|
+
expect(spec.lookups).toHaveLength(2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('reports every problem at once rather than the first', () => {
|
|
137
|
+
const result = parseFormulaSpec({
|
|
138
|
+
variables: [
|
|
139
|
+
{ symbol: 'a', role: 'constant' },
|
|
140
|
+
{ symbol: 'a', role: 'derived' },
|
|
141
|
+
{ symbol: 'b', role: 'wat' },
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
expect(result.ok).toBe(false);
|
|
145
|
+
if (result.ok) return;
|
|
146
|
+
expect(result.errors).toEqual(
|
|
147
|
+
expect.arrayContaining([
|
|
148
|
+
'spec.id is required',
|
|
149
|
+
expect.stringContaining('a constant needs a value'),
|
|
150
|
+
expect.stringContaining("duplicate symbol 'a'"),
|
|
151
|
+
expect.stringContaining('a derived variable needs an expression'),
|
|
152
|
+
expect.stringContaining('role must be one of'),
|
|
153
|
+
]),
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('rejects a branch pointing at an id that does not exist', () => {
|
|
158
|
+
const result = parseFormulaSpec({
|
|
159
|
+
id: 's',
|
|
160
|
+
variables: [],
|
|
161
|
+
piecewise: [{ id: 'p', cases: [{ when: '1', use: 'nope' }] }],
|
|
162
|
+
});
|
|
163
|
+
expect(result.ok).toBe(false);
|
|
164
|
+
if (!result.ok) expect(result.errors[0]).toContain("unknown id 'nope'");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('rejects a lookup row missing a key or its result', () => {
|
|
168
|
+
const result = parseFormulaSpec({
|
|
169
|
+
id: 's',
|
|
170
|
+
variables: [],
|
|
171
|
+
lookups: [{ id: 'l', keys: ['x'], result: 'y', rows: [{ x: 1 }, { y: 2 }] }],
|
|
172
|
+
});
|
|
173
|
+
expect(result.ok).toBe(false);
|
|
174
|
+
if (!result.ok) {
|
|
175
|
+
expect(result.errors).toEqual(
|
|
176
|
+
expect.arrayContaining([
|
|
177
|
+
expect.stringContaining("rows[0] is missing result 'y'"),
|
|
178
|
+
expect.stringContaining("rows[1] is missing key 'x'"),
|
|
179
|
+
]),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('checkLookupCoverage', () => {
|
|
186
|
+
it('names the combinations the source table leaves unspecified', () => {
|
|
187
|
+
const gaps = checkLookupCoverage(spec).filter((g) => g.lookupId === 'fact_di');
|
|
188
|
+
expect(gaps.map((g) => `${g.key.detection}${g.key.isolation}`).sort()).toEqual([
|
|
189
|
+
'BA',
|
|
190
|
+
'CA',
|
|
191
|
+
'CB',
|
|
192
|
+
]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('says nothing about a lookup that declares no domains', () => {
|
|
196
|
+
expect(checkLookupCoverage(spec).some((g) => g.lookupId === 'ld_max')).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('evaluateSpec — the release-rate model', () => {
|
|
201
|
+
it('liquid release rate, with the hole area derived from diameter', () => {
|
|
202
|
+
const r = evaluateSpec(spec, 'liquid-release-rate', { rho_l: 50, Pgauge: 100 });
|
|
203
|
+
expect(r.ok).toBe(true);
|
|
204
|
+
if (!r.ok) return;
|
|
205
|
+
expect(r.value).toBeCloseTo(3.186, 3);
|
|
206
|
+
// The derived area appears in the trace, so the number can be explained.
|
|
207
|
+
expect(r.trace).toContainEqual(
|
|
208
|
+
expect.objectContaining({ kind: 'symbol', symbol: 'An', from: 'derived' }),
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('transition pressure', () => {
|
|
213
|
+
const r = evaluateSpec(spec, 'transition-pressure', {});
|
|
214
|
+
expect(r.ok && r.value).toBeCloseTo(28.71, 2);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('branches to sonic above the transition pressure', () => {
|
|
218
|
+
const r = evaluateSpec(spec, 'vapor-release-rate', { Ps: 100, MW: 30, Ts: 560 });
|
|
219
|
+
expect(r.ok).toBe(true);
|
|
220
|
+
if (!r.ok) return;
|
|
221
|
+
expect(r.value).toBeCloseTo(0.1578, 4);
|
|
222
|
+
expect(r.trace).toContainEqual(
|
|
223
|
+
expect.objectContaining({ kind: 'branch', chose: 'vapor-sonic', label: 'Sonic' }),
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('branches to subsonic at or below the transition pressure', () => {
|
|
228
|
+
const r = evaluateSpec(spec, 'vapor-release-rate', { Ps: 20, MW: 30, Ts: 560 });
|
|
229
|
+
expect(r.ok).toBe(true);
|
|
230
|
+
if (!r.ok) return;
|
|
231
|
+
expect(r.value).toBeCloseTo(0.0281, 4);
|
|
232
|
+
expect(r.trace).toContainEqual(
|
|
233
|
+
expect.objectContaining({ kind: 'branch', chose: 'vapor-subsonic', label: 'Subsonic' }),
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('converts to barrels once the rate is supplied', () => {
|
|
238
|
+
const liquid = evaluateSpec(spec, 'liquid-release-rate', { rho_l: 50, Pgauge: 100 });
|
|
239
|
+
expect(liquid.ok).toBe(true);
|
|
240
|
+
if (!liquid.ok) return;
|
|
241
|
+
const r = evaluateSpec(spec, 'volume-bbl', { Wn: liquid.value, rho_l: 50 });
|
|
242
|
+
expect(r.ok && r.value).toBeCloseTo(40.85, 2); // ReleaseTime defaults to 3600 s
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('evaluateSpec — lookups', () => {
|
|
247
|
+
it('returns the factor for a specified combination', () => {
|
|
248
|
+
const r = evaluateSpec(spec, 'fact_di', { detection: 'A', isolation: 'B' });
|
|
249
|
+
expect(r.ok && r.value).toBe(0.2);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('reports which row matched, so the number can be cited', () => {
|
|
253
|
+
const r = evaluateSpec(spec, 'ld_max', {
|
|
254
|
+
detection: 'A',
|
|
255
|
+
isolation: 'A',
|
|
256
|
+
holeSize: '4 in',
|
|
257
|
+
});
|
|
258
|
+
expect(r.ok && r.value).toBe(5);
|
|
259
|
+
if (!r.ok) return;
|
|
260
|
+
expect(r.trace).toContainEqual(
|
|
261
|
+
expect.objectContaining({ kind: 'lookup', id: 'ld_max', value: 5 }),
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('an unspecified combination is an error, never a silent zero', () => {
|
|
266
|
+
const r = evaluateSpec(spec, 'fact_di', { detection: 'C', isolation: 'A' });
|
|
267
|
+
expect(r.ok).toBe(false);
|
|
268
|
+
if (r.ok) return;
|
|
269
|
+
expect(r.error).toContain('does not specify this combination');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Every case here is a defect an audit found in the first cut. They are the
|
|
274
|
+
// difference between "fails loud" as a claim and as a property.
|
|
275
|
+
describe('regressions — the boundary, not the maths', () => {
|
|
276
|
+
it('a null / undefined / empty input is missing, NOT zero', () => {
|
|
277
|
+
for (const blank of [null, undefined, '']) {
|
|
278
|
+
const r = evaluateSpec(spec, 'liquid-release-rate', {
|
|
279
|
+
rho_l: 50,
|
|
280
|
+
Pgauge: blank as never,
|
|
281
|
+
});
|
|
282
|
+
expect(r.ok, `blank=${JSON.stringify(blank)}`).toBe(false);
|
|
283
|
+
if (!r.ok) expect(r.error).toContain("missing required input 'Pgauge'");
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('comparison and arithmetic agree on a separated number, so the branch is right', () => {
|
|
288
|
+
// '1,000' once compared as a STRING ('1' < '2'), selecting subsonic for a
|
|
289
|
+
// pressure 35x above the transition and returning a plausible wrong number.
|
|
290
|
+
const r = evaluateSpec(spec, 'vapor-release-rate', { Ps: '1,000', MW: 30, Ts: 560 });
|
|
291
|
+
expect(r.ok).toBe(true);
|
|
292
|
+
if (!r.ok) return;
|
|
293
|
+
expect(r.trace).toContainEqual(
|
|
294
|
+
expect.objectContaining({ kind: 'branch', chose: 'vapor-sonic' }),
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('caps coverage expansion instead of exhausting the heap', () => {
|
|
299
|
+
const huge = parseFormulaSpec({
|
|
300
|
+
id: 'h',
|
|
301
|
+
lookups: [
|
|
302
|
+
{
|
|
303
|
+
id: 'big',
|
|
304
|
+
keys: ['a', 'b', 'c', 'd', 'e', 'f'],
|
|
305
|
+
result: 'r',
|
|
306
|
+
domains: Object.fromEntries(
|
|
307
|
+
['a', 'b', 'c', 'd', 'e', 'f'].map((k) => [k, Array.from({ length: 20 }, (_, i) => i)]),
|
|
308
|
+
),
|
|
309
|
+
rows: [{ a: 0, b: 0, c: 0, d: 0, e: 0, f: 0, r: 1 }],
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
});
|
|
313
|
+
expect(huge.ok).toBe(true);
|
|
314
|
+
if (!huge.ok) return;
|
|
315
|
+
const gaps = checkLookupCoverage(huge.spec);
|
|
316
|
+
expect(gaps).toHaveLength(1);
|
|
317
|
+
expect(gaps[0]?.skipped).toContain('above the');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('rejects an expression that does not parse, naming the id', () => {
|
|
321
|
+
const r = parseFormulaSpec({
|
|
322
|
+
id: 's',
|
|
323
|
+
expressions: [{ id: 'beta', expression: '@@@ not an expression(((' }],
|
|
324
|
+
piecewise: [{ id: 'p', cases: [{ when: ')(garbage', use: 'beta' }] }],
|
|
325
|
+
variables: [{ symbol: 'x', role: 'derived', expression: '{{{' }],
|
|
326
|
+
});
|
|
327
|
+
expect(r.ok).toBe(false);
|
|
328
|
+
if (r.ok) return;
|
|
329
|
+
expect(r.errors.join('\n')).toContain("'beta': expression does not parse");
|
|
330
|
+
expect(r.errors.join('\n')).toContain('when does not parse');
|
|
331
|
+
expect(r.errors.join('\n')).toContain("'x': expression does not parse");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('rejects a non-scalar lookup value rather than reading it as zero', () => {
|
|
335
|
+
const r = parseFormulaSpec({
|
|
336
|
+
id: 's',
|
|
337
|
+
lookups: [{ id: 'l', keys: ['k'], result: 'v', rows: [{ k: 'a', v: { value: 0.25 } }] }],
|
|
338
|
+
});
|
|
339
|
+
expect(r.ok).toBe(false);
|
|
340
|
+
if (!r.ok) expect(r.errors.join('\n')).toContain("result 'v' must be a number");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('does not mistake an inherited property for a supplied field', () => {
|
|
344
|
+
const r = parseFormulaSpec({
|
|
345
|
+
id: 's',
|
|
346
|
+
lookups: [{ id: 'l', keys: ['k'], result: 'toString', rows: [{ k: 'a' }] }],
|
|
347
|
+
});
|
|
348
|
+
expect(r.ok).toBe(false);
|
|
349
|
+
if (!r.ok) expect(r.errors.join('\n')).toContain("missing result 'toString'");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('reports a non-array where a list belongs instead of silently emptying it', () => {
|
|
353
|
+
const r = parseFormulaSpec({ id: 's', variables: { a: 1 }, expressions: 'oops', lookups: 3 });
|
|
354
|
+
expect(r.ok).toBe(false);
|
|
355
|
+
if (!r.ok) {
|
|
356
|
+
expect(r.errors).toEqual(
|
|
357
|
+
expect.arrayContaining([
|
|
358
|
+
'variables must be an array',
|
|
359
|
+
'expressions must be an array',
|
|
360
|
+
'lookups must be an array',
|
|
361
|
+
]),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('rejects a source whose sections are not a list, which used to break ingest', () => {
|
|
367
|
+
const r = parseFormulaSpec({ id: 's', source: { standard: 'X', sections: '5.3' } });
|
|
368
|
+
expect(r.ok).toBe(false);
|
|
369
|
+
if (!r.ok) expect(r.errors.join('\n')).toContain('source.sections must be an array of strings');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('rejects a domain naming a key the lookup does not have', () => {
|
|
373
|
+
const r = parseFormulaSpec({
|
|
374
|
+
id: 's',
|
|
375
|
+
lookups: [
|
|
376
|
+
{ id: 'l', keys: ['a'], result: 'r', domains: { b: ['x'] }, rows: [{ a: 'x', r: 1 }] },
|
|
377
|
+
],
|
|
378
|
+
});
|
|
379
|
+
expect(r.ok).toBe(false);
|
|
380
|
+
if (!r.ok) expect(r.errors.join('\n')).toContain("not one of the lookup's keys");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('catches a target cycle with a useful error, not a stack overflow', () => {
|
|
384
|
+
const r = parseFormulaSpec({
|
|
385
|
+
id: 's',
|
|
386
|
+
expressions: [{ id: 'e', expression: '1' }],
|
|
387
|
+
piecewise: [{ id: 'p1', cases: [{ when: '1', use: 'p1' }] }],
|
|
388
|
+
});
|
|
389
|
+
expect(r.ok).toBe(true);
|
|
390
|
+
if (!r.ok) return;
|
|
391
|
+
const out = evaluateSpec(r.spec, 'p1', {});
|
|
392
|
+
expect(out.ok).toBe(false);
|
|
393
|
+
if (!out.ok) {
|
|
394
|
+
expect(out.error).toContain("circular reference resolving target 'p1'");
|
|
395
|
+
expect(out.trace.length).toBeLessThan(10); // not 3738 junk steps
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe('evaluateSpec — failing loud', () => {
|
|
401
|
+
it('a missing input is an error, not a zero', () => {
|
|
402
|
+
const r = evaluateSpec(spec, 'liquid-release-rate', { rho_l: 50 });
|
|
403
|
+
expect(r.ok).toBe(false);
|
|
404
|
+
if (!r.ok) expect(r.error).toContain("missing required input 'Pgauge'");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('symbols are case-sensitive — k and K are different quantities', () => {
|
|
408
|
+
const r = evaluateSpec(spec, 'liquid-release-rate', { rho_l: 50, pgauge: 100 });
|
|
409
|
+
expect(r.ok).toBe(false);
|
|
410
|
+
if (!r.ok) expect(r.error).toContain("missing required input 'Pgauge'");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('a typo in a symbol is an error, not a silent zero', () => {
|
|
414
|
+
const bad = parseFormulaSpec({
|
|
415
|
+
...raw,
|
|
416
|
+
expressions: [{ id: 'e', expression: '{Densty} * 2' }],
|
|
417
|
+
piecewise: [],
|
|
418
|
+
});
|
|
419
|
+
expect(bad.ok).toBe(true);
|
|
420
|
+
if (!bad.ok) return;
|
|
421
|
+
const r = evaluateSpec(bad.spec, 'e', {});
|
|
422
|
+
expect(r.ok).toBe(false);
|
|
423
|
+
if (!r.ok) expect(r.error).toContain("unknown symbol 'Densty'");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('an ambiguous chained symbol names the candidates', () => {
|
|
427
|
+
// Both liquid-release-rate and the vapor piecewise declare Wn, so
|
|
428
|
+
// volume-bbl cannot know which release it is converting.
|
|
429
|
+
const r = evaluateSpec(spec, 'volume-bbl', { rho_l: 50 });
|
|
430
|
+
expect(r.ok).toBe(false);
|
|
431
|
+
if (!r.ok) {
|
|
432
|
+
expect(r.error).toContain('produced by more than one target');
|
|
433
|
+
expect(r.error).toContain('supply it as an input');
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('catches a circular derivation instead of hanging', () => {
|
|
438
|
+
const cyclic = parseFormulaSpec({
|
|
439
|
+
id: 'c',
|
|
440
|
+
variables: [
|
|
441
|
+
{ symbol: 'a', role: 'derived', expression: '{b} + 1' },
|
|
442
|
+
{ symbol: 'b', role: 'derived', expression: '{a} + 1' },
|
|
443
|
+
],
|
|
444
|
+
expressions: [{ id: 'e', expression: '{a}' }],
|
|
445
|
+
});
|
|
446
|
+
expect(cyclic.ok).toBe(true);
|
|
447
|
+
if (!cyclic.ok) return;
|
|
448
|
+
const r = evaluateSpec(cyclic.spec, 'e', {});
|
|
449
|
+
expect(r.ok).toBe(false);
|
|
450
|
+
if (!r.ok) expect(r.error).toContain('circular reference');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('an out-of-domain result is an error rather than a blank', () => {
|
|
454
|
+
const r = evaluateSpec(spec, 'liquid-release-rate', { rho_l: 50, Pgauge: -100 });
|
|
455
|
+
expect(r.ok).toBe(false);
|
|
456
|
+
if (!r.ok) expect(r.error).toContain('out-of-domain');
|
|
457
|
+
});
|
|
458
|
+
});
|