syndicate-js/packages/core/test/dataflow.test.ts

229 lines
7.4 KiB
TypeScript

/// SPDX-License-Identifier: GPL-3.0-or-later
/// SPDX-FileCopyrightText: Copyright © 2016-2024 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
import { Canonicalizer, embeddedId, FlexSet } from '@preserves/core';
import { Cell, Field, Graph } from '../src/runtime/dataflow';
import './test-utils';
class CountingField<V> extends Field<V> {
readCount = 0;
writeCount = 0;
get value(): V {
this.readCount++;
return super.value;
}
set value(v: V) {
this.writeCount++;
super.value = v;
}
spy(): V {
// Retrieve the value without bumping the counters!
return this.__value as V;
}
checkCounts(expectedReadCount: number, expectedWriteCount: number) {
expect(this.readCount).toBe(expectedReadCount);
expect(this.writeCount).toBe(expectedWriteCount);
}
}
class TestGraph<SubjectId> extends Graph<SubjectId, Cell> {
constructor(subjectIdCanonicalizer: Canonicalizer<SubjectId>) {
super(subjectIdCanonicalizer,
Cell.canonicalizer,
g => Array.from(g.values()));
}
checkDamagedNodes(expectedObjects: FlexSet<Cell>) {
expect(this.damagedNodes).is(expectedObjects);
}
}
class BlockGraph extends TestGraph<() => void> {
constructor() {
super(b => '' + embeddedId(b));
}
run(subject: () => void): () => void {
this.withSubject(subject, subject);
return subject;
}
eqn<V>(name: string, f: () => V): CountingField<V> {
const field = new CountingField<V>(this, null!, name);
this.run(() => field.value = f());
return field;
}
}
class StringGraph extends TestGraph<string> {
constructor() {
super(s => s);
}
}
describe('dataflow', () => {
describe('edges, damage and subjects', () => {
it('should be recorded', () => {
const g = new StringGraph();
const c = new CountingField(g, 123);
g.withSubject('s', () => { c.value; });
g.withSubject('t', () => { c.value; });
g.withSubject('s', () => { c.value; });
c.value = 234;
expect(g.damagedNodes.size).toBe(1);
const subjects = new FlexSet<string>(s => s);
g.repairDamage(s => subjects.add(s));
expect(subjects).is(new Set(['s', 't']));
});
});
describe('dataflow blocks', () => {
describe('simple case', () => {
const g = new BlockGraph();
const c = new CountingField(g, 0);
const d = new CountingField(g, 0);
g.run(() => c.value = 123);
g.run(() => d.value = c.value * 2);
it('should be properly initialized', () => {
expect(c.value).toBe(123);
expect(d.value).toBe(246);
});
it('should lead initially to damaged everything', () => {
expect(g.damagedNodes.size).toBe(2);
});
it('should repair idempotently after initialization', () => {
g.repairDamage(c => g.run(c));
expect(c.value).toBe(123);
expect(d.value).toBe(246);
});
it('should be inconsistent after modification but before repair', () => {
c.value = 124;
expect(c.value).toBe(124);
expect(d.value).toBe(246);
});
it('should repair itself properly', () => {
g.repairDamage(c => g.run(c));
expect(c.value).toBe(124);
expect(d.value).toBe(248);
});
});
describe('a more complex case', () => {
const g = new BlockGraph();
const xs = new CountingField(g, [1, 2, 3, 4]);
const sum = g.eqn('sum', () => xs.value.reduce((a, b) => a + b, 0));
const len = g.eqn('len', () => xs.value.length);
const avg = g.eqn('avg', () => {
if (len.value === 0) return null;
return sum.value / len.value;
});
const scale = new CountingField(g, 1, 'scale');
const ans = g.eqn('ans', () => {
if (scale.value === 0) return null;
return typeof avg.value === 'number' && avg.value / scale.value;
});
function expectValues(vs: [
typeof xs.value,
typeof sum.value,
typeof len.value,
typeof avg.value,
typeof scale.value,
typeof ans.value,
]) {
g.repairDamage(c => g.run(c));
expect([
xs.value,
sum.value,
len.value,
avg.value,
scale.value,
ans.value,
]).is(vs);
}
it('initially', () => {
expectValues([[1, 2, 3, 4], 10, 4, 2.5, 1, 2.5]);
});
it('at scale zero', () => {
scale.value = 0;
expectValues([[1, 2, 3, 4], 10, 4, 2.5, 0, null]);
});
it('with nine and zero', () => {
xs.value = [... xs.value, 9, 0];
expectValues([[1, 2, 3, 4, 9, 0], 19, 6, 19 / 6, 0, null]);
});
it('with five and four', () => {
xs.value = [... xs.value.slice(0, -2), 5, 4];
expectValues([[1, 2, 3, 4, 5, 4], 19, 6, 19 / 6, 0, null]);
});
it('at scale one', () => {
scale.value = 1;
expectValues([[1, 2, 3, 4, 5, 4], 19, 6, 19 / 6, 1, 19 / 6]);
});
it('empty', () => {
xs.value = [];
expectValues([[], 0, 0, null, 1, false]);
});
it('four, five, and six', () => {
xs.value = [4, 5, 6];
expectValues([[4, 5, 6], 15, 3, 15 / 3, 1, 15 / 3]);
});
});
});
describe('update batching', () => {
const g = new BlockGraph();
const a = new CountingField(g, 1);
const b = new CountingField(g, 2);
const c = g.eqn('c', () => a.value + b.value);
it('has correct initial values', () => {
expect(a.spy()).toBe(1);
expect(b.spy()).toBe(2);
expect(c.spy()).toBe(3);
});
it('has correct initial counts', () => {
a.checkCounts(1, 0);
b.checkCounts(1, 0);
c.checkCounts(0, 1);
});
it('should repair idempotently after initialization', () => {
g.repairDamage(c => g.run(c));
a.checkCounts(1, 0);
b.checkCounts(1, 0);
c.checkCounts(0, 1);
});
it('should update on the left', () => {
a.value = 3;
g.repairDamage(c => g.run(c));
a.checkCounts(2, 1);
b.checkCounts(2, 0);
c.checkCounts(0, 2);
});
it('should update on the right', () => {
b.value = 3;
g.repairDamage(c => g.run(c));
a.checkCounts(3, 1);
b.checkCounts(3, 1);
c.checkCounts(0, 3);
});
it('should batch simultaneous updates on the left and right', () => {
a.value = 4;
b.value = 4;
g.repairDamage(c => g.run(c));
expect(c.spy()).toBe(8);
c.checkCounts(0, 4);
a.checkCounts(4, 2);
b.checkCounts(4, 2);
});
});
});