@zeix/cause-effect 0.10.1 → 0.12.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/README.md +133 -39
- package/index.d.ts +5 -4
- package/index.js +1 -1
- package/index.ts +5 -4
- package/lib/computed.d.ts +18 -5
- package/lib/computed.ts +111 -43
- package/lib/effect.d.ts +13 -2
- package/lib/effect.ts +28 -9
- package/lib/scheduler.d.ts +40 -0
- package/lib/scheduler.ts +127 -0
- package/lib/signal.d.ts +16 -26
- package/lib/signal.ts +49 -55
- package/lib/state.d.ts +8 -46
- package/lib/state.ts +89 -69
- package/lib/util.d.ts +4 -1
- package/lib/util.ts +18 -1
- package/package.json +5 -2
- package/test/batch.test.ts +99 -0
- package/test/benchmark.test.ts +127 -52
- package/test/computed.test.ts +329 -0
- package/test/effect.test.ts +157 -0
- package/test/state.test.ts +199 -0
- package/test/util/dependency-graph.ts +95 -37
- package/test/util/framework-types.ts +53 -0
- package/test/util/perf-tests.ts +42 -0
- package/test/util/reactive-framework.ts +22 -0
- package/test/cause-effect.test.ts +0 -458
- package/test/util/pseudo-random.ts +0 -45
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from 'bun:test'
|
|
2
|
+
import { state, computed, effect, UNSET } from '../'
|
|
3
|
+
|
|
4
|
+
/* === Utility Functions === */
|
|
5
|
+
|
|
6
|
+
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
7
|
+
|
|
8
|
+
/* === Tests === */
|
|
9
|
+
|
|
10
|
+
describe('Effect', function () {
|
|
11
|
+
|
|
12
|
+
test('should be triggered after a state change', function() {
|
|
13
|
+
const cause = state('foo');
|
|
14
|
+
let count = 0;
|
|
15
|
+
effect((_value) => {
|
|
16
|
+
count++;
|
|
17
|
+
}, cause);
|
|
18
|
+
expect(count).toBe(1);
|
|
19
|
+
cause.set('bar');
|
|
20
|
+
expect(count).toBe(2);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('should be triggered after computed async signals resolve without waterfalls', async function() {
|
|
24
|
+
const a = computed(async () => {
|
|
25
|
+
await wait(100);
|
|
26
|
+
return 10;
|
|
27
|
+
});
|
|
28
|
+
const b = computed(async () => {
|
|
29
|
+
await wait(100);
|
|
30
|
+
return 20;
|
|
31
|
+
});
|
|
32
|
+
let result = 0;
|
|
33
|
+
let count = 0;
|
|
34
|
+
effect((aValue, bValue) => {
|
|
35
|
+
result = aValue + bValue;
|
|
36
|
+
count++;
|
|
37
|
+
}, a, b);
|
|
38
|
+
expect(result).toBe(0);
|
|
39
|
+
expect(count).toBe(0);
|
|
40
|
+
await wait(110);
|
|
41
|
+
expect(result).toBe(30);
|
|
42
|
+
expect(count).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('should be triggered repeatedly after repeated state change', async function() {
|
|
46
|
+
const cause = state(0);
|
|
47
|
+
let result = 0;
|
|
48
|
+
let count = 0;
|
|
49
|
+
effect((res) => {
|
|
50
|
+
result = res;
|
|
51
|
+
count++;
|
|
52
|
+
}, cause);
|
|
53
|
+
for (let i = 0; i < 10; i++) {
|
|
54
|
+
cause.set(i);
|
|
55
|
+
expect(result).toBe(i);
|
|
56
|
+
expect(count).toBe(i + 1); // + 1 for effect initialization
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('should handle errors in effects', function() {
|
|
61
|
+
const a = state(1);
|
|
62
|
+
const b = computed(() => {
|
|
63
|
+
if (a.get() > 5) throw new Error('Value too high');
|
|
64
|
+
return a.get() * 2;
|
|
65
|
+
});
|
|
66
|
+
let normalCallCount = 0;
|
|
67
|
+
let errorCallCount = 0;
|
|
68
|
+
effect({
|
|
69
|
+
ok: (_bValue) => {
|
|
70
|
+
// console.log('Normal effect:', _bValue);
|
|
71
|
+
normalCallCount++;
|
|
72
|
+
},
|
|
73
|
+
err: (error) => {
|
|
74
|
+
// console.log('Error effect:', error);
|
|
75
|
+
errorCallCount++;
|
|
76
|
+
expect(error.message).toBe('Value too high');
|
|
77
|
+
}
|
|
78
|
+
}, b);
|
|
79
|
+
|
|
80
|
+
// Normal case
|
|
81
|
+
a.set(2);
|
|
82
|
+
expect(normalCallCount).toBe(2);
|
|
83
|
+
expect(errorCallCount).toBe(0);
|
|
84
|
+
|
|
85
|
+
// Error case
|
|
86
|
+
a.set(6);
|
|
87
|
+
expect(normalCallCount).toBe(2);
|
|
88
|
+
expect(errorCallCount).toBe(1);
|
|
89
|
+
|
|
90
|
+
// Back to normal
|
|
91
|
+
a.set(3);
|
|
92
|
+
expect(normalCallCount).toBe(3);
|
|
93
|
+
expect(errorCallCount).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('should handle UNSET values in effects', async function() {
|
|
97
|
+
const a = computed(async () => {
|
|
98
|
+
await wait(100);
|
|
99
|
+
return 42;
|
|
100
|
+
});
|
|
101
|
+
let normalCallCount = 0;
|
|
102
|
+
let nilCount = 0;
|
|
103
|
+
effect({
|
|
104
|
+
ok: (aValue) => {
|
|
105
|
+
normalCallCount++;
|
|
106
|
+
expect(aValue).toBe(42);
|
|
107
|
+
},
|
|
108
|
+
nil: () => {
|
|
109
|
+
nilCount++
|
|
110
|
+
}
|
|
111
|
+
}, a);
|
|
112
|
+
|
|
113
|
+
expect(normalCallCount).toBe(0);
|
|
114
|
+
expect(nilCount).toBe(1);
|
|
115
|
+
expect(a.get()).toBe(UNSET);
|
|
116
|
+
await wait(110);
|
|
117
|
+
expect(normalCallCount).toBe(2); // + 1 for effect initialization
|
|
118
|
+
expect(a.get()).toBe(42);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('should log error to console when error is not handled', () => {
|
|
122
|
+
// Mock console.error
|
|
123
|
+
const originalConsoleError = console.error;
|
|
124
|
+
const mockConsoleError = mock((message: string, error: Error) => {});
|
|
125
|
+
console.error = mockConsoleError;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const a = state(1);
|
|
129
|
+
const b = computed(() => {
|
|
130
|
+
if (a.get() > 5) throw new Error('Value too high');
|
|
131
|
+
return a.get() * 2;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Create an effect without explicit error handling
|
|
135
|
+
effect(() => {
|
|
136
|
+
b.get();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// This should trigger the error
|
|
140
|
+
a.set(6);
|
|
141
|
+
|
|
142
|
+
// Check if console.error was called with the expected message
|
|
143
|
+
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
144
|
+
'Unhandled error in effect:',
|
|
145
|
+
expect.any(Error)
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Check the error message
|
|
149
|
+
const [, error] = mockConsoleError.mock.calls[0];
|
|
150
|
+
expect(error.message).toBe('Value too high');
|
|
151
|
+
|
|
152
|
+
} finally {
|
|
153
|
+
// Restore the original console.error
|
|
154
|
+
console.error = originalConsoleError;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { state, isComputed, UNSET } from '../'
|
|
3
|
+
|
|
4
|
+
/* === Tests === */
|
|
5
|
+
|
|
6
|
+
describe('State', function () {
|
|
7
|
+
|
|
8
|
+
describe('Boolean cause', function () {
|
|
9
|
+
|
|
10
|
+
test('should be boolean', function () {
|
|
11
|
+
const cause = state(false);
|
|
12
|
+
expect(typeof cause.get()).toBe('boolean');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('should set initial value to false', function () {
|
|
16
|
+
const cause = state(false);
|
|
17
|
+
expect(cause.get()).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('should set initial value to true', function () {
|
|
21
|
+
const cause = state(true);
|
|
22
|
+
expect(cause.get()).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('should set new value with .set(true)', function () {
|
|
26
|
+
const cause = state(false);
|
|
27
|
+
cause.set(true);
|
|
28
|
+
expect(cause.get()).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('should toggle initial value with .set(v => !v)', function () {
|
|
32
|
+
const cause = state(false);
|
|
33
|
+
cause.update((v) => !v);
|
|
34
|
+
expect(cause.get()).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('Number cause', function () {
|
|
40
|
+
|
|
41
|
+
test('should be number', function () {
|
|
42
|
+
const cause = state(0);
|
|
43
|
+
expect(typeof cause.get()).toBe('number');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('should set initial value to 0', function () {
|
|
47
|
+
const cause = state(0);
|
|
48
|
+
expect(cause.get()).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('should set new value with .set(42)', function () {
|
|
52
|
+
const cause = state(0);
|
|
53
|
+
cause.set(42);
|
|
54
|
+
expect(cause.get()).toBe(42);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('should increment value with .set(v => ++v)', function () {
|
|
58
|
+
const cause = state(0);
|
|
59
|
+
cause.update(v => ++v);
|
|
60
|
+
expect(cause.get()).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('String cause', function () {
|
|
66
|
+
|
|
67
|
+
test('should be string', function () {
|
|
68
|
+
const cause = state('foo');
|
|
69
|
+
expect(typeof cause.get()).toBe('string');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('should set initial value to "foo"', function () {
|
|
73
|
+
const cause = state('foo');
|
|
74
|
+
expect(cause.get()).toBe('foo');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('should set new value with .set("bar")', function () {
|
|
78
|
+
const cause = state('foo');
|
|
79
|
+
cause.set('bar');
|
|
80
|
+
expect(cause.get()).toBe('bar');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('should upper case value with .set(v => v.toUpperCase())', function () {
|
|
84
|
+
const cause = state('foo');
|
|
85
|
+
cause.update(v => v ? v.toUpperCase() : '');
|
|
86
|
+
expect(cause.get()).toBe("FOO");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('Array cause', function () {
|
|
92
|
+
|
|
93
|
+
test('should be array', function () {
|
|
94
|
+
const cause = state([1, 2, 3]);
|
|
95
|
+
expect(Array.isArray(cause.get())).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('should set initial value to [1, 2, 3]', function () {
|
|
99
|
+
const cause = state([1, 2, 3]);
|
|
100
|
+
expect(cause.get()).toEqual([1, 2, 3]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('should set new value with .set([4, 5, 6])', function () {
|
|
104
|
+
const cause = state([1, 2, 3]);
|
|
105
|
+
cause.set([4, 5, 6]);
|
|
106
|
+
expect(cause.get()).toEqual([4, 5, 6]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('should reflect current value of array after modification', function () {
|
|
110
|
+
const array = [1, 2, 3];
|
|
111
|
+
const cause = state(array);
|
|
112
|
+
array.push(4); // don't do this! the result will be correct, but we can't trigger effects
|
|
113
|
+
expect(cause.get()).toEqual([1, 2, 3, 4]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('should set new value with .set([...array, 4])', function () {
|
|
117
|
+
const array = [1, 2, 3];
|
|
118
|
+
const cause = state(array);
|
|
119
|
+
cause.set([...array, 4]); // use destructuring instead!
|
|
120
|
+
expect(cause.get()).toEqual([1, 2, 3, 4]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('Object cause', function () {
|
|
126
|
+
|
|
127
|
+
test('should be object', function () {
|
|
128
|
+
const cause = state({ a: 'a', b: 1 });
|
|
129
|
+
expect(typeof cause.get()).toBe('object');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('should set initial value to { a: "a", b: 1 }', function () {
|
|
133
|
+
const cause = state({ a: 'a', b: 1 });
|
|
134
|
+
expect(cause.get()).toEqual({ a: 'a', b: 1 });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('should set new value with .set({ c: true })', function () {
|
|
138
|
+
const cause = state<Record<string, any>>({ a: 'a', b: 1 });
|
|
139
|
+
cause.set({ c: true });
|
|
140
|
+
expect(cause.get()).toEqual({ c: true });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('should reflect current value of object after modification', function () {
|
|
144
|
+
const obj = { a: 'a', b: 1 };
|
|
145
|
+
const cause = state<Record<string, any>>(obj);
|
|
146
|
+
// @ts-expect-error
|
|
147
|
+
obj.c = true; // don't do this! the result will be correct, but we can't trigger effects
|
|
148
|
+
expect(cause.get()).toEqual({ a: 'a', b: 1, c: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('should set new value with .set({...obj, c: true})', function () {
|
|
152
|
+
const obj = { a: 'a', b: 1 };
|
|
153
|
+
const cause = state<Record<string, any>>(obj);
|
|
154
|
+
cause.set({...obj, c: true}); // use destructuring instead!
|
|
155
|
+
expect(cause.get()).toEqual({ a: 'a', b: 1, c: true });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('Map method', function () {
|
|
161
|
+
|
|
162
|
+
test('should return a computed signal', function() {
|
|
163
|
+
const cause = state(42);
|
|
164
|
+
const double = cause.map(v => v * 2);
|
|
165
|
+
expect(isComputed(double)).toBe(true);
|
|
166
|
+
expect(double.get()).toBe(84);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('Match method', function () {
|
|
172
|
+
|
|
173
|
+
test('should create an effect that reacts on signal changes', function() {
|
|
174
|
+
const cause = state(42);
|
|
175
|
+
let okCount = 0;
|
|
176
|
+
let nilCount = 0;
|
|
177
|
+
let result: number = 0;
|
|
178
|
+
cause.match({
|
|
179
|
+
ok: v => {
|
|
180
|
+
result = v;
|
|
181
|
+
okCount++
|
|
182
|
+
},
|
|
183
|
+
nil: () => {
|
|
184
|
+
nilCount++
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
cause.set(43);
|
|
188
|
+
expect(okCount).toBe(2); // + 1 for effect initialization
|
|
189
|
+
expect(nilCount).toBe(0);
|
|
190
|
+
expect(result).toBe(43);
|
|
191
|
+
|
|
192
|
+
cause.set(UNSET);
|
|
193
|
+
expect(okCount).toBe(2);
|
|
194
|
+
expect(nilCount).toBe(1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
});
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TestConfig } from "./framework-types";
|
|
2
|
+
import { Computed, ReactiveFramework, Signal } from "./reactive-framework";
|
|
3
|
+
import { Random } from "random";
|
|
4
|
+
|
|
5
|
+
export interface Graph {
|
|
6
|
+
sources: Signal<number>[];
|
|
7
|
+
layers: Computed<number>[][];
|
|
8
|
+
}
|
|
9
|
+
|
|
2
10
|
/**
|
|
3
11
|
* Make a rectangular dependency graph, with an equal number of source elements
|
|
4
12
|
* and computation elements at every layer.
|
|
@@ -8,11 +16,15 @@ import { pseudoRandom } from './pseudo-random'
|
|
|
8
16
|
* @param staticFraction every nth computed node is static (1 = all static, 3 = 2/3rd are dynamic)
|
|
9
17
|
* @returns the graph
|
|
10
18
|
*/
|
|
11
|
-
export function makeGraph(
|
|
19
|
+
export function makeGraph(
|
|
20
|
+
framework: ReactiveFramework,
|
|
21
|
+
config: TestConfig,
|
|
22
|
+
counter: Counter
|
|
23
|
+
): Graph {
|
|
12
24
|
const { width, totalLayers, staticFraction, nSources } = config;
|
|
25
|
+
|
|
13
26
|
return framework.withBuild(() => {
|
|
14
27
|
const sources = new Array(width).fill(0).map((_, i) => framework.signal(i));
|
|
15
|
-
const counter = new Counter();
|
|
16
28
|
const rows = makeDependentRows(
|
|
17
29
|
sources,
|
|
18
30
|
totalLayers - 1,
|
|
@@ -22,54 +34,95 @@ export function makeGraph(framework, config) {
|
|
|
22
34
|
framework
|
|
23
35
|
);
|
|
24
36
|
const graph = { sources, layers: rows };
|
|
25
|
-
return
|
|
37
|
+
return graph;
|
|
26
38
|
});
|
|
27
39
|
}
|
|
40
|
+
|
|
28
41
|
/**
|
|
29
42
|
* Execute the graph by writing one of the sources and reading some or all of the leaves.
|
|
30
43
|
*
|
|
31
44
|
* @return the sum of all leaf values
|
|
32
45
|
*/
|
|
33
|
-
export function runGraph(
|
|
34
|
-
|
|
46
|
+
export function runGraph(
|
|
47
|
+
graph: Graph,
|
|
48
|
+
iterations: number,
|
|
49
|
+
readFraction: number,
|
|
50
|
+
framework: ReactiveFramework
|
|
51
|
+
): number {
|
|
52
|
+
const rand = new Random("seed");
|
|
35
53
|
const { sources, layers } = graph;
|
|
36
54
|
const leaves = layers[layers.length - 1];
|
|
37
55
|
const skipCount = Math.round(leaves.length * (1 - readFraction));
|
|
38
56
|
const readLeaves = removeElems(leaves, skipCount, rand);
|
|
39
|
-
|
|
57
|
+
const frameworkName = framework.name.toLowerCase();
|
|
58
|
+
// const start = Date.now();
|
|
59
|
+
let sum = 0;
|
|
60
|
+
|
|
61
|
+
if (frameworkName === "s-js" || frameworkName === "solidjs") {
|
|
62
|
+
// [S.js freeze](https://github.com/adamhaile/S#sdatavalue) doesn't allow different values to be set during a single batch, so special case it.
|
|
63
|
+
for (let i = 0; i < iterations; i++) {
|
|
64
|
+
framework.withBatch(() => {
|
|
65
|
+
const sourceDex = i % sources.length;
|
|
66
|
+
sources[sourceDex].write(i + sourceDex);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
for (const leaf of readLeaves) {
|
|
70
|
+
leaf.read();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
sum = readLeaves.reduce((total, leaf) => leaf.read() + total, 0);
|
|
75
|
+
} else {
|
|
40
76
|
framework.withBatch(() => {
|
|
41
|
-
|
|
42
|
-
|
|
77
|
+
for (let i = 0; i < iterations; i++) {
|
|
78
|
+
// Useful for debugging edge cases for some frameworks that experience
|
|
79
|
+
// dramatic slow downs for certain test configurations. These are generally
|
|
80
|
+
// due to `computed` effects not being cached efficiently, and as the number
|
|
81
|
+
// of layers increases, the uncached `computed` effects are re-evaluated in
|
|
82
|
+
// an `O(n^2)` manner where `n` is the number of layers.
|
|
83
|
+
/* if (i % 100 === 0) {
|
|
84
|
+
console.log("iteration:", i, "delta:", Date.now() - start);
|
|
85
|
+
} */
|
|
86
|
+
|
|
87
|
+
const sourceDex = i % sources.length;
|
|
88
|
+
sources[sourceDex].write(i + sourceDex);
|
|
89
|
+
|
|
90
|
+
for (const leaf of readLeaves) {
|
|
91
|
+
leaf.read();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
sum = readLeaves.reduce((total, leaf) => leaf.read() + total, 0);
|
|
43
96
|
});
|
|
44
|
-
for (const leaf of readLeaves) {
|
|
45
|
-
leaf.read();
|
|
46
|
-
}
|
|
47
97
|
}
|
|
48
|
-
|
|
98
|
+
|
|
49
99
|
return sum;
|
|
50
100
|
}
|
|
51
|
-
|
|
101
|
+
|
|
102
|
+
function removeElems<T>(src: T[], rmCount: number, rand: Random): T[] {
|
|
52
103
|
const copy = src.slice();
|
|
53
104
|
for (let i = 0; i < rmCount; i++) {
|
|
54
|
-
const rmDex =
|
|
105
|
+
const rmDex = rand.int(0, copy.length - 1);
|
|
55
106
|
copy.splice(rmDex, 1);
|
|
56
107
|
}
|
|
57
108
|
return copy;
|
|
58
109
|
}
|
|
110
|
+
|
|
59
111
|
export class Counter {
|
|
60
|
-
|
|
112
|
+
count = 0;
|
|
61
113
|
}
|
|
114
|
+
|
|
62
115
|
function makeDependentRows(
|
|
63
|
-
sources,
|
|
64
|
-
numRows,
|
|
65
|
-
counter,
|
|
66
|
-
staticFraction,
|
|
67
|
-
nSources,
|
|
68
|
-
framework
|
|
69
|
-
):
|
|
116
|
+
sources: Computed<number>[],
|
|
117
|
+
numRows: number,
|
|
118
|
+
counter: Counter,
|
|
119
|
+
staticFraction: number,
|
|
120
|
+
nSources: number,
|
|
121
|
+
framework: ReactiveFramework
|
|
122
|
+
): Computed<number>[][] {
|
|
70
123
|
let prevRow = sources;
|
|
71
|
-
const
|
|
72
|
-
const rows
|
|
124
|
+
const rand = new Random("seed");
|
|
125
|
+
const rows = [];
|
|
73
126
|
for (let l = 0; l < numRows; l++) {
|
|
74
127
|
const row = makeRow(
|
|
75
128
|
prevRow,
|
|
@@ -78,32 +131,35 @@ function makeDependentRows(
|
|
|
78
131
|
nSources,
|
|
79
132
|
framework,
|
|
80
133
|
l,
|
|
81
|
-
|
|
134
|
+
rand
|
|
82
135
|
);
|
|
83
|
-
rows.push(row);
|
|
136
|
+
rows.push(row as never);
|
|
84
137
|
prevRow = row;
|
|
85
138
|
}
|
|
86
139
|
return rows;
|
|
87
140
|
}
|
|
141
|
+
|
|
88
142
|
function makeRow(
|
|
89
|
-
sources,
|
|
90
|
-
counter,
|
|
91
|
-
staticFraction,
|
|
92
|
-
nSources,
|
|
93
|
-
framework,
|
|
94
|
-
|
|
95
|
-
random
|
|
96
|
-
) {
|
|
143
|
+
sources: Computed<number>[],
|
|
144
|
+
counter: Counter,
|
|
145
|
+
staticFraction: number,
|
|
146
|
+
nSources: number,
|
|
147
|
+
framework: ReactiveFramework,
|
|
148
|
+
_layer: number,
|
|
149
|
+
random: Random
|
|
150
|
+
): Computed<number>[] {
|
|
97
151
|
return sources.map((_, myDex) => {
|
|
98
|
-
const mySources:
|
|
152
|
+
const mySources: Computed<number>[] = [];
|
|
99
153
|
for (let sourceDex = 0; sourceDex < nSources; sourceDex++) {
|
|
100
154
|
mySources.push(sources[(myDex + sourceDex) % sources.length]);
|
|
101
155
|
}
|
|
102
|
-
|
|
156
|
+
|
|
157
|
+
const staticNode = random.float() < staticFraction;
|
|
103
158
|
if (staticNode) {
|
|
104
159
|
// static node, always reference sources
|
|
105
160
|
return framework.computed(() => {
|
|
106
161
|
counter.count++;
|
|
162
|
+
|
|
107
163
|
let sum = 0;
|
|
108
164
|
for (const src of mySources) {
|
|
109
165
|
sum += src.read();
|
|
@@ -119,10 +175,12 @@ function makeRow(
|
|
|
119
175
|
let sum = first.read();
|
|
120
176
|
const shouldDrop = sum & 0x1;
|
|
121
177
|
const dropDex = sum % tail.length;
|
|
178
|
+
|
|
122
179
|
for (let i = 0; i < tail.length; i++) {
|
|
123
180
|
if (shouldDrop && i === dropDex) continue;
|
|
124
181
|
sum += tail[i].read();
|
|
125
182
|
}
|
|
183
|
+
|
|
126
184
|
return sum;
|
|
127
185
|
});
|
|
128
186
|
return node;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { TestResult } from "./perf-tests";
|
|
2
|
+
import { ReactiveFramework } from "./reactive-framework";
|
|
3
|
+
|
|
4
|
+
/** Parameters for a running a performance benchmark test
|
|
5
|
+
*
|
|
6
|
+
* The benchmarks create a rectangular grid of reactive elements, with
|
|
7
|
+
* mutable signals in the first level, computed elements in the middle levels,
|
|
8
|
+
* and read effect elements in the last level.
|
|
9
|
+
*
|
|
10
|
+
* Each test iteration modifies one signal, and then reads specified
|
|
11
|
+
* fraction of the effect elements.
|
|
12
|
+
*
|
|
13
|
+
* Each non-signal node sums values from a specified number of elements
|
|
14
|
+
* in the preceding layer. Some nodes are dynamic, and read vary
|
|
15
|
+
* the number of sources the read for the sum.
|
|
16
|
+
*
|
|
17
|
+
* Tests may optionally provide result values to verify the sum
|
|
18
|
+
* of all read effect elements in all iterations, and the total
|
|
19
|
+
* number of non-signal updated.
|
|
20
|
+
*/
|
|
21
|
+
export interface TestConfig {
|
|
22
|
+
/** friendly name for the test, should be unique */
|
|
23
|
+
name?: string;
|
|
24
|
+
|
|
25
|
+
/** width of dependency graph to construct */
|
|
26
|
+
width: number;
|
|
27
|
+
|
|
28
|
+
/** depth of dependency graph to construct */
|
|
29
|
+
totalLayers: number;
|
|
30
|
+
|
|
31
|
+
/** fraction of nodes that are static */ // TODO change to dynamicFraction
|
|
32
|
+
staticFraction: number;
|
|
33
|
+
|
|
34
|
+
/** construct a graph with number of sources in each node */
|
|
35
|
+
nSources: number;
|
|
36
|
+
|
|
37
|
+
/** fraction of [0, 1] elements in the last layer from which to read values in each test iteration */
|
|
38
|
+
readFraction: number;
|
|
39
|
+
|
|
40
|
+
/** number of test iterations */
|
|
41
|
+
iterations: number;
|
|
42
|
+
|
|
43
|
+
/** sum and count of all iterations, for verification */
|
|
44
|
+
expected: Partial<TestResult>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FrameworkInfo {
|
|
48
|
+
/** wrapper/adapter for a benchmarking a reactive framework */
|
|
49
|
+
framework: ReactiveFramework;
|
|
50
|
+
|
|
51
|
+
/** verify the number of nodes executed matches the expected number */
|
|
52
|
+
testPullCounts?: boolean;
|
|
53
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { FrameworkInfo, TestConfig } from "./framework-types";
|
|
2
|
+
|
|
3
|
+
export interface TestResult {
|
|
4
|
+
sum: number;
|
|
5
|
+
count: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TimingResult<T> {
|
|
9
|
+
result: T;
|
|
10
|
+
timing: TestTiming;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TestTiming {
|
|
14
|
+
time: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function verifyBenchResult(
|
|
18
|
+
perfFramework: FrameworkInfo,
|
|
19
|
+
config: TestConfig,
|
|
20
|
+
timedResult: TimingResult<TestResult>
|
|
21
|
+
): void {
|
|
22
|
+
const { testPullCounts, framework } = perfFramework;
|
|
23
|
+
const { expected } = config;
|
|
24
|
+
const { result } = timedResult;
|
|
25
|
+
|
|
26
|
+
if (expected.sum) {
|
|
27
|
+
console.assert(
|
|
28
|
+
result.sum == expected.sum,
|
|
29
|
+
`sum ${framework.name} ${config.name} result:${result.sum} expected:${expected.sum}`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (
|
|
33
|
+
expected.count &&
|
|
34
|
+
(config.readFraction === 1 || testPullCounts) &&
|
|
35
|
+
testPullCounts !== false
|
|
36
|
+
) {
|
|
37
|
+
console.assert(
|
|
38
|
+
result.count === expected.count,
|
|
39
|
+
`count ${framework.name} ${config.name} result:${result.count} expected:${expected.count}`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** interface for a reactive framework.
|
|
2
|
+
*
|
|
3
|
+
* Implement this interface to add a new reactive framework to the test and performance test suite.
|
|
4
|
+
*/
|
|
5
|
+
export interface ReactiveFramework {
|
|
6
|
+
name: string;
|
|
7
|
+
signal<T>(initialValue: T): Signal<T>;
|
|
8
|
+
computed<T>(fn: () => T): Computed<T>;
|
|
9
|
+
effect(fn: () => void): void;
|
|
10
|
+
withBatch<T>(fn: () => T): void;
|
|
11
|
+
withBuild<T>(fn: () => T): T;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Signal<T> {
|
|
15
|
+
read(): T;
|
|
16
|
+
write(v: T): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Computed<T> {
|
|
20
|
+
read(): T;
|
|
21
|
+
}
|
|
22
|
+
|