@plures/praxis 1.4.4 → 2.0.3
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/README.md +164 -1067
- package/dist/browser/chunk-IUEKGHQN.js +373 -0
- package/dist/browser/factory/index.d.ts +2 -1
- package/dist/browser/index.d.ts +7 -4
- package/dist/browser/index.js +18 -6
- package/dist/browser/integrations/svelte.d.ts +4 -3
- package/dist/browser/project/index.d.ts +2 -1
- package/dist/browser/{reactive-engine.svelte-DgVTqHLc.d.ts → reactive-engine.svelte-BwWadvAW.d.ts} +2 -1
- package/dist/browser/rule-result-DcXWe9tn.d.ts +206 -0
- package/dist/browser/{rules-i1LHpnGd.d.ts → rules-BaWMqxuG.d.ts} +2 -205
- package/dist/browser/unified/index.d.ts +239 -0
- package/dist/browser/unified/index.js +20 -0
- package/dist/node/chunk-IUEKGHQN.js +373 -0
- package/dist/node/cli/index.js +1 -1
- package/dist/node/index.cjs +377 -0
- package/dist/node/index.d.cts +4 -2
- package/dist/node/index.d.ts +4 -2
- package/dist/node/index.js +19 -7
- package/dist/node/integrations/svelte.d.cts +3 -2
- package/dist/node/integrations/svelte.d.ts +3 -2
- package/dist/node/integrations/svelte.js +2 -2
- package/dist/node/{reactive-engine.svelte-DekxqFu0.d.ts → reactive-engine.svelte-BBZLMzus.d.ts} +3 -79
- package/dist/node/{reactive-engine.svelte-Cg0Yc2Hs.d.cts → reactive-engine.svelte-Cbq_V20o.d.cts} +3 -79
- package/dist/node/rule-result-B9GMivAn.d.cts +80 -0
- package/dist/node/rule-result-Bo3sFMmN.d.ts +80 -0
- package/dist/node/unified/index.cjs +494 -0
- package/dist/node/unified/index.d.cts +240 -0
- package/dist/node/unified/index.d.ts +240 -0
- package/dist/node/unified/index.js +21 -0
- package/docs/README.md +58 -102
- package/docs/archive/1.x/CONVERSATIONS_IMPLEMENTATION.md +207 -0
- package/docs/archive/1.x/DECISION_LEDGER_IMPLEMENTATION.md +109 -0
- package/docs/archive/1.x/DECISION_LEDGER_SUMMARY.md +424 -0
- package/docs/archive/1.x/ELEVATION_SUMMARY.md +249 -0
- package/docs/archive/1.x/FEATURE_SUMMARY.md +238 -0
- package/docs/archive/1.x/GOLDEN_PATH_IMPLEMENTATION.md +280 -0
- package/docs/archive/1.x/IMPLEMENTATION.md +166 -0
- package/docs/archive/1.x/IMPLEMENTATION_COMPLETE.md +389 -0
- package/docs/archive/1.x/IMPLEMENTATION_SUMMARY.md +59 -0
- package/docs/archive/1.x/INTEGRATION_ENHANCEMENT_SUMMARY.md +238 -0
- package/docs/archive/1.x/KNO_ENG_REFACTORING_SUMMARY.md +198 -0
- package/docs/archive/1.x/MONOREPO_SUMMARY.md +158 -0
- package/docs/archive/1.x/README.md +28 -0
- package/docs/archive/1.x/SVELTE_INTEGRATION_SUMMARY.md +415 -0
- package/docs/archive/1.x/TASK_1_COMPLETE.md +235 -0
- package/docs/archive/1.x/TASK_1_SUMMARY.md +281 -0
- package/docs/archive/1.x/VERSION_0.2.0_RELEASE_NOTES.md +288 -0
- package/docs/archive/1.x/ValidationChecklist.md +7 -0
- package/package.json +13 -1
- package/src/index.browser.ts +20 -0
- package/src/index.ts +21 -0
- package/src/unified/__tests__/unified-qa.test.ts +761 -0
- package/src/unified/__tests__/unified.test.ts +396 -0
- package/src/unified/core.ts +534 -0
- package/src/unified/index.ts +32 -0
- package/src/unified/rules.ts +66 -0
- package/src/unified/types.ts +148 -0
- package/dist/node/{chunk-ZO2LU4G4.js → chunk-WFRHXZBP.js} +3 -3
- package/dist/node/{validate-5PSWJTIC.js → validate-BY7JNY7H.js} +1 -1
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Praxis Unified Reactive Layer — Deep QA Suite
|
|
3
|
+
*
|
|
4
|
+
* Stress tests, edge cases, concurrency, memory pressure, and correctness
|
|
5
|
+
* validation for the v2.0 unified API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
createApp,
|
|
11
|
+
definePath,
|
|
12
|
+
defineRule,
|
|
13
|
+
defineConstraint,
|
|
14
|
+
RuleResult,
|
|
15
|
+
fact,
|
|
16
|
+
} from '../index.js';
|
|
17
|
+
|
|
18
|
+
// ── Schema ──────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const Counter = definePath<number>('counter', 0);
|
|
21
|
+
const Items = definePath<Array<{ id: number; name: string; done: boolean }>>('items', []);
|
|
22
|
+
const Nested = definePath<{ a: { b: { c: number } } }>('nested', { a: { b: { c: 0 } } });
|
|
23
|
+
const NullablePath = definePath<string | null>('nullable', null);
|
|
24
|
+
const Loading = definePath<boolean>('loading', false);
|
|
25
|
+
const Error_ = definePath<string | null>('error', null);
|
|
26
|
+
|
|
27
|
+
// ── Edge Case Tests ─────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe('Unified QA — Edge Cases', () => {
|
|
30
|
+
describe('schema defaults', () => {
|
|
31
|
+
it('returns correct initial for each type', () => {
|
|
32
|
+
const app = createApp({ name: 'test', schema: [Counter, Items, NullablePath, Loading] });
|
|
33
|
+
expect(app.query<number>('counter').current).toBe(0);
|
|
34
|
+
expect(app.query<any[]>('items').current).toEqual([]);
|
|
35
|
+
expect(app.query<string | null>('nullable').current).toBeNull();
|
|
36
|
+
expect(app.query<boolean>('loading').current).toBe(false);
|
|
37
|
+
app.destroy();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('querying undefined path returns undefined, not crash', () => {
|
|
41
|
+
const app = createApp({ name: 'test', schema: [] });
|
|
42
|
+
const ref = app.query<string>('nonexistent');
|
|
43
|
+
expect(ref.current).toBeUndefined();
|
|
44
|
+
app.destroy();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('mutate edge cases', () => {
|
|
49
|
+
it('mutating to same value still notifies subscribers', () => {
|
|
50
|
+
const app = createApp({ name: 'test', schema: [Counter] });
|
|
51
|
+
const values: number[] = [];
|
|
52
|
+
app.query<number>('counter').subscribe(v => values.push(v));
|
|
53
|
+
|
|
54
|
+
app.mutate('counter', 0); // same as initial
|
|
55
|
+
// Should still notify (we don't do deep equality checks on mutate)
|
|
56
|
+
expect(values.length).toBeGreaterThanOrEqual(2);
|
|
57
|
+
app.destroy();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('mutating undefined path auto-creates it', () => {
|
|
61
|
+
const app = createApp({ name: 'test', schema: [] });
|
|
62
|
+
const result = app.mutate('dynamic/path', 42);
|
|
63
|
+
expect(result.accepted).toBe(true);
|
|
64
|
+
expect(app.query<number>('dynamic/path').current).toBe(42);
|
|
65
|
+
app.destroy();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('mutating null → value → null roundtrip', () => {
|
|
69
|
+
const app = createApp({ name: 'test', schema: [NullablePath] });
|
|
70
|
+
const values: Array<string | null> = [];
|
|
71
|
+
app.query<string | null>('nullable').subscribe(v => values.push(v));
|
|
72
|
+
|
|
73
|
+
app.mutate('nullable', 'hello');
|
|
74
|
+
app.mutate('nullable', null);
|
|
75
|
+
|
|
76
|
+
expect(values).toEqual([null, 'hello', null]);
|
|
77
|
+
app.destroy();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('mutating with complex objects (arrays of objects)', () => {
|
|
81
|
+
const app = createApp({ name: 'test', schema: [Items] });
|
|
82
|
+
const bigList = Array.from({ length: 1000 }, (_, i) => ({
|
|
83
|
+
id: i,
|
|
84
|
+
name: `Item ${i}`,
|
|
85
|
+
done: i % 2 === 0,
|
|
86
|
+
}));
|
|
87
|
+
const result = app.mutate('items', bigList);
|
|
88
|
+
expect(result.accepted).toBe(true);
|
|
89
|
+
expect(app.query<any[]>('items').current.length).toBe(1000);
|
|
90
|
+
app.destroy();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('subscriber lifecycle', () => {
|
|
95
|
+
it('unsubscribe prevents further notifications', () => {
|
|
96
|
+
const app = createApp({ name: 'test', schema: [Counter] });
|
|
97
|
+
const values: number[] = [];
|
|
98
|
+
const ref = app.query<number>('counter');
|
|
99
|
+
const unsub = ref.subscribe(v => values.push(v));
|
|
100
|
+
|
|
101
|
+
app.mutate('counter', 1);
|
|
102
|
+
unsub();
|
|
103
|
+
app.mutate('counter', 2);
|
|
104
|
+
app.mutate('counter', 3);
|
|
105
|
+
|
|
106
|
+
expect(values).toEqual([0, 1]); // initial + first mutate only
|
|
107
|
+
app.destroy();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('multiple subscribe/unsubscribe cycles work', () => {
|
|
111
|
+
const app = createApp({ name: 'test', schema: [Counter] });
|
|
112
|
+
const ref = app.query<number>('counter');
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i < 100; i++) {
|
|
115
|
+
const vals: number[] = [];
|
|
116
|
+
const unsub = ref.subscribe(v => vals.push(v));
|
|
117
|
+
expect(vals.length).toBe(1); // immediate callback
|
|
118
|
+
unsub();
|
|
119
|
+
}
|
|
120
|
+
app.destroy();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('subscriber error does not break other subscribers', () => {
|
|
124
|
+
const app = createApp({ name: 'test', schema: [Counter] });
|
|
125
|
+
const ref = app.query<number>('counter');
|
|
126
|
+
const good: number[] = [];
|
|
127
|
+
|
|
128
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
129
|
+
|
|
130
|
+
ref.subscribe(() => { throw new Error('boom'); });
|
|
131
|
+
ref.subscribe(v => good.push(v));
|
|
132
|
+
|
|
133
|
+
app.mutate('counter', 42);
|
|
134
|
+
|
|
135
|
+
expect(good).toEqual([0, 42]);
|
|
136
|
+
consoleError.mockRestore();
|
|
137
|
+
app.destroy();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('query options — deep', () => {
|
|
142
|
+
it('where + sort + limit compose correctly', () => {
|
|
143
|
+
const app = createApp({ name: 'test', schema: [Items] });
|
|
144
|
+
app.mutate('items', [
|
|
145
|
+
{ id: 5, name: 'E', done: false },
|
|
146
|
+
{ id: 1, name: 'A', done: true },
|
|
147
|
+
{ id: 3, name: 'C', done: false },
|
|
148
|
+
{ id: 2, name: 'B', done: false },
|
|
149
|
+
{ id: 4, name: 'D', done: true },
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
const ref = app.query<Array<{ id: number; name: string; done: boolean }>>('items', {
|
|
153
|
+
where: item => !item.done,
|
|
154
|
+
sort: (a, b) => a.name.localeCompare(b.name),
|
|
155
|
+
limit: 2,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(ref.current).toEqual([
|
|
159
|
+
{ id: 2, name: 'B', done: false },
|
|
160
|
+
{ id: 3, name: 'C', done: false },
|
|
161
|
+
]);
|
|
162
|
+
app.destroy();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('query options update reactively on mutate', () => {
|
|
166
|
+
const app = createApp({ name: 'test', schema: [Items] });
|
|
167
|
+
const ref = app.query<Array<{ id: number; name: string; done: boolean }>>('items', {
|
|
168
|
+
where: item => item.done,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const snapshots: any[] = [];
|
|
172
|
+
ref.subscribe(v => snapshots.push([...v]));
|
|
173
|
+
|
|
174
|
+
app.mutate('items', [{ id: 1, name: 'A', done: false }]);
|
|
175
|
+
app.mutate('items', [{ id: 1, name: 'A', done: true }]);
|
|
176
|
+
|
|
177
|
+
expect(snapshots[0]).toEqual([]); // initial
|
|
178
|
+
expect(snapshots[1]).toEqual([]); // not done
|
|
179
|
+
expect(snapshots[2]).toEqual([{ id: 1, name: 'A', done: true }]); // now done
|
|
180
|
+
app.destroy();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('empty where returns empty array, not crash', () => {
|
|
184
|
+
const app = createApp({ name: 'test', schema: [Items] });
|
|
185
|
+
app.mutate('items', [{ id: 1, name: 'A', done: false }]);
|
|
186
|
+
const ref = app.query<any[]>('items', { where: () => false });
|
|
187
|
+
expect(ref.current).toEqual([]);
|
|
188
|
+
app.destroy();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── Constraint Tests ────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
describe('Unified QA — Constraints', () => {
|
|
196
|
+
const maxItems = defineConstraint({
|
|
197
|
+
id: 'max-items',
|
|
198
|
+
watch: ['items'],
|
|
199
|
+
validate: (values) => {
|
|
200
|
+
const items = (values['items'] ?? []) as any[];
|
|
201
|
+
if (items.length > 50) return 'Too many items (max 50)';
|
|
202
|
+
return true;
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const noNegativeCounter = defineConstraint({
|
|
207
|
+
id: 'no-negative',
|
|
208
|
+
watch: ['counter'],
|
|
209
|
+
validate: (values) => {
|
|
210
|
+
if ((values['counter'] as number) < 0) return 'Counter cannot be negative';
|
|
211
|
+
return true;
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('constraint blocks mutation and preserves old value', () => {
|
|
216
|
+
const app = createApp({
|
|
217
|
+
name: 'test',
|
|
218
|
+
schema: [Counter],
|
|
219
|
+
constraints: [noNegativeCounter],
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
app.mutate('counter', 5);
|
|
223
|
+
const result = app.mutate('counter', -1);
|
|
224
|
+
|
|
225
|
+
expect(result.accepted).toBe(false);
|
|
226
|
+
expect(result.violations[0].message).toContain('negative');
|
|
227
|
+
expect(app.query<number>('counter').current).toBe(5); // preserved
|
|
228
|
+
app.destroy();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('multiple constraints on same path all run', () => {
|
|
232
|
+
const alsoNoZero = defineConstraint({
|
|
233
|
+
id: 'no-zero',
|
|
234
|
+
watch: ['counter'],
|
|
235
|
+
validate: (values) => {
|
|
236
|
+
if ((values['counter'] as number) === 0) return 'Counter cannot be zero';
|
|
237
|
+
return true;
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const app = createApp({
|
|
242
|
+
name: 'test',
|
|
243
|
+
schema: [Counter],
|
|
244
|
+
constraints: [noNegativeCounter, alsoNoZero],
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Start at 0, try to set 0 — noZero blocks
|
|
248
|
+
app.mutate('counter', 5);
|
|
249
|
+
const result = app.mutate('counter', 0);
|
|
250
|
+
expect(result.accepted).toBe(false);
|
|
251
|
+
expect(result.violations.some(v => v.message.includes('zero'))).toBe(true);
|
|
252
|
+
app.destroy();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('constraint that throws is caught and reported', () => {
|
|
256
|
+
const throwingConstraint = defineConstraint({
|
|
257
|
+
id: 'throws',
|
|
258
|
+
watch: ['counter'],
|
|
259
|
+
validate: () => { throw new Error('kaboom'); },
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const app = createApp({
|
|
263
|
+
name: 'test',
|
|
264
|
+
schema: [Counter],
|
|
265
|
+
constraints: [throwingConstraint],
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const result = app.mutate('counter', 1);
|
|
269
|
+
expect(result.accepted).toBe(false);
|
|
270
|
+
expect(result.violations[0].message).toContain('kaboom');
|
|
271
|
+
app.destroy();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('constraint on collection checks proposed value', () => {
|
|
275
|
+
const app = createApp({
|
|
276
|
+
name: 'test',
|
|
277
|
+
schema: [Items],
|
|
278
|
+
constraints: [maxItems],
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const bigList = Array.from({ length: 51 }, (_, i) => ({ id: i, name: `Item ${i}`, done: false }));
|
|
282
|
+
const result = app.mutate('items', bigList);
|
|
283
|
+
expect(result.accepted).toBe(false);
|
|
284
|
+
expect(result.violations[0].message).toContain('max 50');
|
|
285
|
+
app.destroy();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ── Rule Evaluation Tests ───────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
describe('Unified QA — Rules', () => {
|
|
292
|
+
const counterHighRule = defineRule({
|
|
293
|
+
id: 'counter.high',
|
|
294
|
+
watch: ['counter'],
|
|
295
|
+
evaluate: (values) => {
|
|
296
|
+
const c = values['counter'] as number;
|
|
297
|
+
if (c > 100) return RuleResult.emit([fact('counter.high', { value: c })]);
|
|
298
|
+
return RuleResult.retract(['counter.high']);
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const itemsEmptyRule = defineRule({
|
|
303
|
+
id: 'items.empty',
|
|
304
|
+
watch: ['items'],
|
|
305
|
+
evaluate: (values) => {
|
|
306
|
+
const items = (values['items'] ?? []) as any[];
|
|
307
|
+
if (items.length === 0) return RuleResult.emit([fact('items.empty', {})]);
|
|
308
|
+
return RuleResult.retract(['items.empty']);
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('rule emits fact when condition met', () => {
|
|
313
|
+
const app = createApp({
|
|
314
|
+
name: 'test',
|
|
315
|
+
schema: [Counter],
|
|
316
|
+
rules: [counterHighRule],
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
app.mutate('counter', 200);
|
|
320
|
+
expect(app.facts().some(f => f.tag === 'counter.high')).toBe(true);
|
|
321
|
+
app.destroy();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('rule retracts fact when condition no longer met', () => {
|
|
325
|
+
const app = createApp({
|
|
326
|
+
name: 'test',
|
|
327
|
+
schema: [Counter],
|
|
328
|
+
rules: [counterHighRule],
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
app.mutate('counter', 200);
|
|
332
|
+
expect(app.facts().some(f => f.tag === 'counter.high')).toBe(true);
|
|
333
|
+
|
|
334
|
+
app.mutate('counter', 50);
|
|
335
|
+
expect(app.facts().some(f => f.tag === 'counter.high')).toBe(false);
|
|
336
|
+
app.destroy();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('multiple rules evaluate independently', () => {
|
|
340
|
+
const app = createApp({
|
|
341
|
+
name: 'test',
|
|
342
|
+
schema: [Counter, Items],
|
|
343
|
+
rules: [counterHighRule, itemsEmptyRule],
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Initially: items empty, counter low
|
|
347
|
+
app.mutate('counter', 0);
|
|
348
|
+
expect(app.facts().some(f => f.tag === 'items.empty')).toBe(true);
|
|
349
|
+
expect(app.facts().some(f => f.tag === 'counter.high')).toBe(false);
|
|
350
|
+
|
|
351
|
+
// Now counter high, items still empty
|
|
352
|
+
app.mutate('counter', 200);
|
|
353
|
+
expect(app.facts().some(f => f.tag === 'counter.high')).toBe(true);
|
|
354
|
+
expect(app.facts().some(f => f.tag === 'items.empty')).toBe(true);
|
|
355
|
+
|
|
356
|
+
// Add items
|
|
357
|
+
app.mutate('items', [{ id: 1, name: 'A', done: false }]);
|
|
358
|
+
expect(app.facts().some(f => f.tag === 'items.empty')).toBe(false);
|
|
359
|
+
expect(app.facts().some(f => f.tag === 'counter.high')).toBe(true);
|
|
360
|
+
|
|
361
|
+
app.destroy();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('rule that throws does not crash app', () => {
|
|
365
|
+
const badRule = defineRule({
|
|
366
|
+
id: 'bad',
|
|
367
|
+
watch: ['counter'],
|
|
368
|
+
evaluate: () => { throw new Error('rule crash'); },
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
372
|
+
const app = createApp({
|
|
373
|
+
name: 'test',
|
|
374
|
+
schema: [Counter],
|
|
375
|
+
rules: [badRule, counterHighRule],
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Should not throw
|
|
379
|
+
app.mutate('counter', 200);
|
|
380
|
+
// Good rule still works
|
|
381
|
+
expect(app.facts().some(f => f.tag === 'counter.high')).toBe(true);
|
|
382
|
+
consoleError.mockRestore();
|
|
383
|
+
app.destroy();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('fact LWW: same tag emitted twice keeps latest', () => {
|
|
387
|
+
let callCount = 0;
|
|
388
|
+
const versionedRule = defineRule({
|
|
389
|
+
id: 'versioned',
|
|
390
|
+
watch: ['counter'],
|
|
391
|
+
evaluate: (values) => {
|
|
392
|
+
callCount++;
|
|
393
|
+
return RuleResult.emit([fact('version', { v: callCount, counter: values['counter'] })]);
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const app = createApp({
|
|
398
|
+
name: 'test',
|
|
399
|
+
schema: [Counter],
|
|
400
|
+
rules: [versionedRule],
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
app.mutate('counter', 1);
|
|
404
|
+
app.mutate('counter', 2);
|
|
405
|
+
app.mutate('counter', 3);
|
|
406
|
+
|
|
407
|
+
const facts = app.facts().filter(f => f.tag === 'version');
|
|
408
|
+
expect(facts.length).toBe(1); // LWW — only latest
|
|
409
|
+
expect((facts[0].payload as any).counter).toBe(3);
|
|
410
|
+
app.destroy();
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// ── Batch Tests ─────────────────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
describe('Unified QA — Batch', () => {
|
|
417
|
+
it('batch with zero mutations succeeds', () => {
|
|
418
|
+
const app = createApp({ name: 'test', schema: [Counter] });
|
|
419
|
+
const result = app.batch(() => {});
|
|
420
|
+
expect(result.accepted).toBe(true);
|
|
421
|
+
expect(result.violations).toEqual([]);
|
|
422
|
+
app.destroy();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('batch rules only evaluate once (not per mutation)', () => {
|
|
426
|
+
let evalCount = 0;
|
|
427
|
+
const countingRule = defineRule({
|
|
428
|
+
id: 'counting',
|
|
429
|
+
watch: ['counter', 'loading'],
|
|
430
|
+
evaluate: () => {
|
|
431
|
+
evalCount++;
|
|
432
|
+
return RuleResult.noop();
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const app = createApp({
|
|
437
|
+
name: 'test',
|
|
438
|
+
schema: [Counter, Loading],
|
|
439
|
+
rules: [countingRule],
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
evalCount = 0;
|
|
443
|
+
app.batch((m) => {
|
|
444
|
+
m('counter', 1);
|
|
445
|
+
m('loading', true);
|
|
446
|
+
m('counter', 2);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
expect(evalCount).toBe(1); // single evaluation pass
|
|
450
|
+
app.destroy();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('batch partial constraint failure rejects all', () => {
|
|
454
|
+
const noNeg = defineConstraint({
|
|
455
|
+
id: 'no-neg',
|
|
456
|
+
watch: ['counter'],
|
|
457
|
+
validate: (v) => (v['counter'] as number) >= 0 ? true : 'Negative!',
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const app = createApp({
|
|
461
|
+
name: 'test',
|
|
462
|
+
schema: [Counter, Loading],
|
|
463
|
+
constraints: [noNeg],
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const result = app.batch((m) => {
|
|
467
|
+
m('loading', true); // fine
|
|
468
|
+
m('counter', -5); // violates
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
expect(result.accepted).toBe(false);
|
|
472
|
+
// Loading should NOT have been applied
|
|
473
|
+
expect(app.query<boolean>('loading').current).toBe(false);
|
|
474
|
+
app.destroy();
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// ── Timeline Tests ──────────────────────────────────────────────────────────
|
|
479
|
+
|
|
480
|
+
describe('Unified QA — Timeline', () => {
|
|
481
|
+
it('timeline entries have monotonically increasing timestamps', () => {
|
|
482
|
+
const app = createApp({ name: 'test', schema: [Counter] });
|
|
483
|
+
for (let i = 0; i < 20; i++) {
|
|
484
|
+
app.mutate('counter', i);
|
|
485
|
+
}
|
|
486
|
+
const tl = app.timeline();
|
|
487
|
+
for (let i = 1; i < tl.length; i++) {
|
|
488
|
+
expect(tl[i].timestamp).toBeGreaterThanOrEqual(tl[i - 1].timestamp);
|
|
489
|
+
}
|
|
490
|
+
app.destroy();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('timeline caps at max entries', () => {
|
|
494
|
+
const app = createApp({ name: 'test', schema: [Counter] });
|
|
495
|
+
// Timeline max is 10000
|
|
496
|
+
for (let i = 0; i < 200; i++) {
|
|
497
|
+
app.mutate('counter', i);
|
|
498
|
+
}
|
|
499
|
+
const tl = app.timeline();
|
|
500
|
+
// With rules, each mutate creates 1 mutation entry
|
|
501
|
+
// Should stay under max
|
|
502
|
+
expect(tl.length).toBeLessThanOrEqual(10000);
|
|
503
|
+
expect(tl.length).toBeGreaterThan(0);
|
|
504
|
+
app.destroy();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('timeline records constraint violations', () => {
|
|
508
|
+
const noNeg = defineConstraint({
|
|
509
|
+
id: 'no-neg',
|
|
510
|
+
watch: ['counter'],
|
|
511
|
+
validate: (v) => (v['counter'] as number) >= 0 ? true : 'Negative!',
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const app = createApp({
|
|
515
|
+
name: 'test',
|
|
516
|
+
schema: [Counter],
|
|
517
|
+
constraints: [noNeg],
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
app.mutate('counter', -1);
|
|
521
|
+
const tl = app.timeline();
|
|
522
|
+
expect(tl.some(e => e.kind === 'constraint-check')).toBe(true);
|
|
523
|
+
app.destroy();
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ── Liveness Tests ──────────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
describe('Unified QA — Liveness', () => {
|
|
530
|
+
it('liveness() returns empty when no liveness config', () => {
|
|
531
|
+
const app = createApp({ name: 'test', schema: [Counter] });
|
|
532
|
+
expect(app.liveness()).toEqual({});
|
|
533
|
+
app.destroy();
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('liveness tracks multiple paths', async () => {
|
|
537
|
+
const app = createApp({
|
|
538
|
+
name: 'test',
|
|
539
|
+
schema: [Counter, Loading, Error_],
|
|
540
|
+
liveness: {
|
|
541
|
+
expect: ['counter', 'loading', 'error'],
|
|
542
|
+
timeoutMs: 30,
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
app.mutate('counter', 1);
|
|
547
|
+
// loading and error not mutated
|
|
548
|
+
|
|
549
|
+
await new Promise(r => setTimeout(r, 50));
|
|
550
|
+
|
|
551
|
+
const status = app.liveness();
|
|
552
|
+
expect(status['counter'].stale).toBe(false);
|
|
553
|
+
expect(status['loading'].stale).toBe(true);
|
|
554
|
+
expect(status['error'].stale).toBe(true);
|
|
555
|
+
app.destroy();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('onStale fires only for un-updated paths', async () => {
|
|
559
|
+
const stalePaths: string[] = [];
|
|
560
|
+
const app = createApp({
|
|
561
|
+
name: 'test',
|
|
562
|
+
schema: [Counter, Loading],
|
|
563
|
+
liveness: {
|
|
564
|
+
expect: ['counter', 'loading'],
|
|
565
|
+
timeoutMs: 30,
|
|
566
|
+
onStale: (path) => stalePaths.push(path),
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
app.mutate('counter', 1);
|
|
571
|
+
await new Promise(r => setTimeout(r, 50));
|
|
572
|
+
|
|
573
|
+
expect(stalePaths).toContain('loading');
|
|
574
|
+
expect(stalePaths).not.toContain('counter');
|
|
575
|
+
app.destroy();
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// ── Stress Tests ────────────────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
describe('Unified QA — Stress', () => {
|
|
582
|
+
it('handles 10,000 rapid mutations', () => {
|
|
583
|
+
const app = createApp({
|
|
584
|
+
name: 'test',
|
|
585
|
+
schema: [Counter],
|
|
586
|
+
rules: [defineRule({
|
|
587
|
+
id: 'always',
|
|
588
|
+
watch: ['counter'],
|
|
589
|
+
evaluate: () => RuleResult.noop(),
|
|
590
|
+
})],
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const start = performance.now();
|
|
594
|
+
for (let i = 0; i < 10000; i++) {
|
|
595
|
+
app.mutate('counter', i);
|
|
596
|
+
}
|
|
597
|
+
const elapsed = performance.now() - start;
|
|
598
|
+
|
|
599
|
+
expect(app.query<number>('counter').current).toBe(9999);
|
|
600
|
+
// Should complete in under 2 seconds even on slow CI
|
|
601
|
+
expect(elapsed).toBeLessThan(2000);
|
|
602
|
+
app.destroy();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('handles 50 concurrent subscribers', () => {
|
|
606
|
+
const app = createApp({ name: 'test', schema: [Counter] });
|
|
607
|
+
const results: number[][] = [];
|
|
608
|
+
const unsubs: Array<() => void> = [];
|
|
609
|
+
|
|
610
|
+
for (let i = 0; i < 50; i++) {
|
|
611
|
+
results[i] = [];
|
|
612
|
+
const ref = app.query<number>('counter');
|
|
613
|
+
unsubs.push(ref.subscribe(v => results[i].push(v)));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
app.mutate('counter', 42);
|
|
617
|
+
|
|
618
|
+
for (let i = 0; i < 50; i++) {
|
|
619
|
+
expect(results[i]).toEqual([0, 42]);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
unsubs.forEach(u => u());
|
|
623
|
+
app.destroy();
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('handles 100 rules without degradation', () => {
|
|
627
|
+
const rules = Array.from({ length: 100 }, (_, i) => defineRule({
|
|
628
|
+
id: `rule-${i}`,
|
|
629
|
+
watch: ['counter'],
|
|
630
|
+
evaluate: (values) => {
|
|
631
|
+
const c = values['counter'] as number;
|
|
632
|
+
if (c > i) return RuleResult.emit([fact(`above-${i}`, { c })]);
|
|
633
|
+
return RuleResult.retract([`above-${i}`]);
|
|
634
|
+
},
|
|
635
|
+
}));
|
|
636
|
+
|
|
637
|
+
const app = createApp({
|
|
638
|
+
name: 'test',
|
|
639
|
+
schema: [Counter],
|
|
640
|
+
rules,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
const start = performance.now();
|
|
644
|
+
app.mutate('counter', 50);
|
|
645
|
+
const elapsed = performance.now() - start;
|
|
646
|
+
|
|
647
|
+
const facts = app.facts();
|
|
648
|
+
expect(facts.length).toBe(50); // above-0 through above-49
|
|
649
|
+
expect(elapsed).toBeLessThan(100); // should be nearly instant
|
|
650
|
+
app.destroy();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it('handles large collections in query options', () => {
|
|
654
|
+
const app = createApp({ name: 'test', schema: [Items] });
|
|
655
|
+
const bigList = Array.from({ length: 10000 }, (_, i) => ({
|
|
656
|
+
id: i,
|
|
657
|
+
name: `Item ${i}`,
|
|
658
|
+
done: i % 3 === 0,
|
|
659
|
+
}));
|
|
660
|
+
app.mutate('items', bigList);
|
|
661
|
+
|
|
662
|
+
const ref = app.query<any[]>('items', {
|
|
663
|
+
where: item => item.done,
|
|
664
|
+
sort: (a, b) => b.id - a.id,
|
|
665
|
+
limit: 10,
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
const result = ref.current;
|
|
669
|
+
expect(result.length).toBe(10);
|
|
670
|
+
expect(result[0].id).toBe(9999); // highest done id
|
|
671
|
+
app.destroy();
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// ── Destroy Tests ───────────────────────────────────────────────────────────
|
|
676
|
+
|
|
677
|
+
describe('Unified QA — Destroy', () => {
|
|
678
|
+
it('subscribers stop receiving after destroy', () => {
|
|
679
|
+
const app = createApp({ name: 'test', schema: [Counter] });
|
|
680
|
+
const values: number[] = [];
|
|
681
|
+
app.query<number>('counter').subscribe(v => values.push(v));
|
|
682
|
+
|
|
683
|
+
app.destroy();
|
|
684
|
+
// This should not throw, but also not notify
|
|
685
|
+
app.mutate('counter', 999);
|
|
686
|
+
expect(values).toEqual([0]); // only initial
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it('facts and timeline are cleared', () => {
|
|
690
|
+
const app = createApp({
|
|
691
|
+
name: 'test',
|
|
692
|
+
schema: [Counter],
|
|
693
|
+
rules: [defineRule({
|
|
694
|
+
id: 'test',
|
|
695
|
+
watch: ['counter'],
|
|
696
|
+
evaluate: () => RuleResult.emit([fact('test', {})]),
|
|
697
|
+
})],
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
app.mutate('counter', 1);
|
|
701
|
+
expect(app.facts().length).toBeGreaterThan(0);
|
|
702
|
+
expect(app.timeline().length).toBeGreaterThan(0);
|
|
703
|
+
|
|
704
|
+
app.destroy();
|
|
705
|
+
expect(app.facts()).toEqual([]);
|
|
706
|
+
expect(app.timeline()).toEqual([]);
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
describe('auto-retraction on skip/noop', () => {
|
|
711
|
+
it('retracts facts when rule transitions from emit to skip', () => {
|
|
712
|
+
const app = createApp({
|
|
713
|
+
name: 'auto-retract-test',
|
|
714
|
+
schema: [definePath<number | null>('val', null)],
|
|
715
|
+
rules: [defineRule({
|
|
716
|
+
id: 'r1',
|
|
717
|
+
watch: ['val'],
|
|
718
|
+
evaluate: (values) => {
|
|
719
|
+
const v = values['val'] as number | null;
|
|
720
|
+
if (v === null) return RuleResult.skip('no data');
|
|
721
|
+
if (v > 5) return RuleResult.emit([fact('big', { v })]);
|
|
722
|
+
return RuleResult.retract(['big']);
|
|
723
|
+
},
|
|
724
|
+
})],
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// Emit a fact
|
|
728
|
+
app.mutate('val', 10);
|
|
729
|
+
expect(app.facts().find(f => f.tag === 'big')).toBeDefined();
|
|
730
|
+
|
|
731
|
+
// Now skip — fact should auto-retract
|
|
732
|
+
app.mutate('val', null);
|
|
733
|
+
expect(app.facts().find(f => f.tag === 'big')).toBeUndefined();
|
|
734
|
+
|
|
735
|
+
app.destroy();
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('retracts facts when rule transitions from emit to noop', () => {
|
|
739
|
+
const app = createApp({
|
|
740
|
+
name: 'auto-retract-noop-test',
|
|
741
|
+
schema: [definePath<number>('val', 0)],
|
|
742
|
+
rules: [defineRule({
|
|
743
|
+
id: 'r1',
|
|
744
|
+
watch: ['val'],
|
|
745
|
+
evaluate: (values) => {
|
|
746
|
+
const v = values['val'] as number;
|
|
747
|
+
if (v > 5) return RuleResult.emit([fact('big', { v })]);
|
|
748
|
+
return RuleResult.noop();
|
|
749
|
+
},
|
|
750
|
+
})],
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
app.mutate('val', 10);
|
|
754
|
+
expect(app.facts().find(f => f.tag === 'big')).toBeDefined();
|
|
755
|
+
|
|
756
|
+
app.mutate('val', 3);
|
|
757
|
+
expect(app.facts().find(f => f.tag === 'big')).toBeUndefined();
|
|
758
|
+
|
|
759
|
+
app.destroy();
|
|
760
|
+
});
|
|
761
|
+
});
|