'use strict';
let React;
let ReactNoop;
let Scheduler;
describe('ReactIncrementalTriangle', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
});
function span(prop) {
return {type: 'span', children: [], prop, hidden: false};
}
const FLUSH = 'FLUSH';
function flush(unitsOfWork = Infinity) {
return {
type: FLUSH,
unitsOfWork,
};
}
const FLUSH_ALL = 'FLUSH_ALL';
function flushAll() {
return {
type: FLUSH_ALL,
};
}
const STEP = 'STEP';
function step(counter) {
return {
type: STEP,
counter,
};
}
const INTERRUPT = 'INTERRUPT';
function interrupt(key) {
return {
type: INTERRUPT,
};
}
const TOGGLE = 'TOGGLE';
function toggle(childIndex) {
return {
type: TOGGLE,
childIndex,
};
}
const EXPIRE = 'EXPIRE';
function expire(ms) {
return {
type: EXPIRE,
ms,
};
}
const STOP = 'STOP';
function randomInteger(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min)) + min;
}
function formatAction(action) {
switch (action.type) {
case FLUSH:
return `flush(${action.unitsOfWork})`;
case FLUSH_ALL:
return 'flushAll()';
case STEP:
return `step(${action.counter})`;
case INTERRUPT:
return 'interrupt()';
case TOGGLE:
return `toggle(${action.childIndex})`;
case EXPIRE:
return `expire(${action.ms})`;
default:
throw new Error('Switch statement should be exhaustive');
}
}
function formatActions(actions) {
let result = 'simulate(';
for (let i = 0; i < actions.length; i++) {
const action = actions[i];
result += formatAction(action);
if (i !== actions.length - 1) {
result += ', ';
}
}
result += ')';
return result;
}
const MAX_DEPTH = 3;
const TOTAL_CHILDREN = Math.pow(3, MAX_DEPTH);
let TOTAL_TRIANGLES = 0;
for (let i = 0; i <= MAX_DEPTH; i++) {
TOTAL_TRIANGLES += Math.pow(3, i);
}
function randomAction() {
const weights = [
[FLUSH, 1],
[FLUSH_ALL, 1],
[STEP, 1],
[INTERRUPT, 1],
[TOGGLE, 1],
[EXPIRE, 1],
];
let totalWeight = 0;
for (let i = 0; i < weights.length; i++) {
totalWeight += weights[i][1];
}
const randomNumber = Math.random() * totalWeight;
let actionType;
let remainingWeight = randomNumber;
for (let i = 0; i < weights.length; i++) {
const [option, weight] = weights[i];
remainingWeight -= weight;
if (remainingWeight <= 0) {
actionType = option;
break;
}
}
switch (actionType) {
case FLUSH:
return flush(randomInteger(0, TOTAL_TRIANGLES * 1.5));
case FLUSH_ALL:
return flushAll();
case STEP:
return step(randomInteger(0, 10));
case INTERRUPT:
return interrupt();
case TOGGLE:
return toggle(randomInteger(0, TOTAL_TRIANGLES));
case EXPIRE:
return expire(randomInteger(0, 1500));
default:
throw new Error('Switch statement should be exhaustive');
}
}
function randomActions(n) {
const actions = [];
for (let i = 0; i < n; i++) {
actions.push(randomAction());
}
return actions;
}
function TriangleSimulator(rootID) {
const CounterContext = React.createContext([]);
const ActiveContext = React.createContext(0);
let triangles = [];
let leafTriangles = [];
const yieldAfterEachRender = false;
class Triangle extends React.Component {
constructor(props) {
super();
this.state = {isActive: false};
this.child = React.createRef(null);
}
activate() {
this.setState({isActive: true});
}
deactivate() {
this.setState({isActive: false});
}
shouldComponentUpdate(nextProps, nextState) {
return (
this.props.counter !== nextProps.counter ||
this.props.activeDepth !== nextProps.activeDepth ||
this.state.isActive !== nextState.isActive
);
}
componentDidMount() {
this.index = triangles.length;
triangles.push(this);
if (this.props.remainingDepth === 0) {
this.leafIndex = leafTriangles.length;
leafTriangles.push(this);
}
}
componentDidUpdate() {
if (this.child.current !== null) {
const {prop: currentCounter} = JSON.parse(this.child.current.prop);
if (this.props.counter !== currentCounter) {
throw new Error('Incorrect props in lifecycle');
}
}
}
render() {
if (yieldAfterEachRender) {
Scheduler.log(this);
}
const {counter, remainingDepth} = this.props;
return (
<ActiveContext.Consumer>
{activeContext => (
<CounterContext.Consumer>
{counterContext => {
const activeDepthProp = this.state.isActive
? this.props.activeDepth + 1
: this.props.activeDepth;
const activeDepthContext = this.state.isActive
? activeContext + 1
: activeContext;
if (remainingDepth === 0) {
// Leaf
const output = JSON.stringify({
prop: counter,
isActive: this.state.isActive,
counterContext: counterContext,
activeDepthProp,
activeDepthContext,
});
return <span ref={this.child} prop={output} />;
}
return (
<ActiveContext.Provider value={activeDepthContext}>
<CounterContext.Provider value={counter}>
<Triangle
counter={counter}
activeDepth={activeDepthProp}
remainingDepth={remainingDepth - 1}
/>
<Triangle
counter={counter}
activeDepth={activeDepthProp}
remainingDepth={remainingDepth - 1}
/>
<Triangle
counter={counter}
activeDepth={activeDepthProp}
remainingDepth={remainingDepth - 1}
/>
</CounterContext.Provider>
</ActiveContext.Provider>
);
}}
</CounterContext.Consumer>
)}
</ActiveContext.Consumer>
);
}
}
let appInstance;
class App extends React.Component {
state = {counter: 0};
interrupt() {
this.forceUpdate();
}
setCounter(counter) {
const currentCounter = this.state.counter;
this.setState({counter});
return currentCounter;
}
render() {
appInstance = this;
return (
<Triangle
counter={this.state.counter}
activeDepth={0}
remainingDepth={this.props.remainingDepth}
/>
);
}
}
let keyCounter = 0;
function reset(nextStep = 0) {
triangles = [];
leafTriangles = [];
if (rootID) {
ReactNoop.renderToRootWithID(
<App remainingDepth={MAX_DEPTH} key={keyCounter++} />,
rootID,
);
} else {
ReactNoop.render(<App remainingDepth={MAX_DEPTH} key={keyCounter++} />);
}
Scheduler.unstable_flushAllWithoutAsserting();
assertConsistentTree();
return appInstance;
}
reset();
function assertConsistentTree(activeTriangleIndices = new Set(), counter) {
const childrenJSX = ReactNoop.getPendingChildrenAsJSX(rootID);
const children = childrenJSX === null ? [] : childrenJSX.props.children;
if (children.length !== TOTAL_CHILDREN) {
throw new Error('Wrong number of children.');
}
let expectedCounter = counter;
for (let i = 0; i < children.length; i++) {
const child = children[i];
const output = JSON.parse(child.props.prop);
const prop = output.prop;
const isActive = output.isActive;
const counterContext = output.counterContext;
const activeDepthProp = output.activeDepthProp;
const activeDepthContext = output.activeDepthContext;
if (expectedCounter === undefined) {
expectedCounter = prop;
}
const expectedIsActive = activeTriangleIndices.has(i);
if (prop !== expectedCounter) {
throw new Error(
`Triangle ${i} is inconsistent: prop ${prop} instead of ` +
expectedCounter,
);
}
if (isActive !== expectedIsActive) {
throw new Error(
`Triangle ${i} is inconsistent: isActive ${isActive} instead of ` +
expectedIsActive,
);
}
if (counterContext !== prop) {
throw new Error(
`Triangle ${i} is inconsistent: prop ${prop} does not match ` +
`counterContext ${counterContext}`,
);
}
if (activeDepthContext !== activeDepthProp) {
throw new Error(
`Triangle ${i} is inconsistent: activeDepthProp ` +
`${activeDepthProp} does not match activeDepthContext ` +
activeDepthContext,
);
}
}
}
function* simulateAndYield() {
const app = reset();
let expectedCounterAtEnd = app.state.counter;
const activeIndices = new Set();
const activeLeafIndices = new Set();
let action;
while (true) {
action = yield;
if (action === STOP) {
break;
}
ReactNoop.flushSync(() => {
switch (action.type) {
case FLUSH:
Scheduler.unstable_flushNumberOfYields(action.unitsOfWork);
break;
case FLUSH_ALL:
Scheduler.unstable_flushAllWithoutAsserting();
break;
case STEP:
ReactNoop.deferredUpdates(() => {
app.setCounter(action.counter);
expectedCounterAtEnd = action.counter;
});
break;
case INTERRUPT:
app.interrupt();
break;
case TOGGLE:
const targetTriangle = triangles[action.childIndex];
if (targetTriangle === undefined) {
throw new Error('Target index is out of bounds');
}
const index = targetTriangle.index;
const leafIndex = targetTriangle.leafIndex;
if (activeIndices.has(index)) {
activeIndices.delete(index);
if (leafIndex !== undefined) {
activeLeafIndices.delete(leafIndex);
}
targetTriangle.deactivate();
} else {
activeIndices.add(index);
if (leafIndex !== undefined) {
activeLeafIndices.add(leafIndex);
}
targetTriangle.activate();
}
break;
case EXPIRE:
ReactNoop.expire(action.ms);
break;
default:
throw new Error('Switch statement should be exhaustive');
}
});
assertConsistentTree(activeLeafIndices);
}
Scheduler.unstable_flushAllWithoutAsserting();
ReactNoop.flushSync();
assertConsistentTree(activeLeafIndices, expectedCounterAtEnd);
}
function simulate(...actions) {
const gen = simulateAndYield();
gen.next();
for (const action of actions) {
gen.next(action);
}
gen.next(STOP);
}
return {
simulateAndYield,
simulate,
randomAction,
randomActions,
};
}
describe('single root', () => {
it('hard-coded tests', () => {
const {simulate} = TriangleSimulator();
simulate(step(1));
simulate(toggle(0), step(1), toggle(0));
simulate(step(1), toggle(0), flush(2), step(2), toggle(0));
simulate(step(1), flush(3), toggle(0), step(0));
simulate(step(1), flush(3), toggle(18), step(0));
simulate(step(4), flush(52), expire(1476), flush(17), step(0));
simulate(interrupt(), toggle(10), step(2), expire(990), flush(46));
simulate(interrupt(), step(6), step(7), toggle(6), interrupt());
simulate(interrupt(), toggle(31), toggle(31), toggle(13), step(1));
});
it('generative tests', () => {
const {simulate} = TriangleSimulator();
const limit = 1000;
for (let i = 0; i < limit; i++) {
const actions = randomActions(5);
try {
simulate(...actions);
} catch (e) {
console.error(
`Triangle fuzz tester error! Copy and paste the following line into the test suite:
${formatActions(actions)}
`,
);
throw e;
}
}
});
});
describe('multiple roots', () => {
const rootIDs = ['a', 'b', 'c'];
function randomActionsPerRoot() {
function randomRootID() {
const index = randomInteger(0, rootIDs.length);
return rootIDs[index];
}
const actions = [];
for (let i = 0; i < 10; i++) {
const rootID = randomRootID();
const action = randomAction();
actions.push([rootID, action]);
}
return actions;
}
function formatActionsPerRoot(actions) {
let result = 'simulateMultipleRoots(';
for (let i = 0; i < actions.length; i++) {
const [rootID, action] = actions[i];
result += `['${rootID}', ${formatAction(action)}]`;
if (i !== actions.length - 1) {
result += ', ';
}
}
result += ')';
return result;
}
function simulateMultipleRoots(...actions) {
const roots = new Map();
for (const rootID of rootIDs) {
const simulator = TriangleSimulator(rootID);
const generator = simulator.simulateAndYield();
generator.next();
roots.set(rootID, generator);
}
actions.forEach(([rootID, action]) => {
const generator = roots.get(rootID);
generator.next(action);
});
roots.forEach(generator => {
generator.next(STOP);
});
}
it('hard-coded tests', () => {
simulateMultipleRoots(
['b', interrupt()],
['a', toggle(22)],
['c', step(4)],
['a', expire(10)],
['a', interrupt()],
['c', step(2)],
['b', interrupt()],
);
simulateMultipleRoots(
['c', toggle(0)],
['c', step(1)],
['b', flush(7)],
['c', toggle(0)],
);
simulateMultipleRoots(
['c', step(1)],
['c', expire(5000)],
['b', toggle(1)],
);
});
it('generative tests', () => {
const limit = 100;
for (let i = 0; i < limit; i++) {
const actions = randomActionsPerRoot();
try {
simulateMultipleRoots(...actions);
} catch (e) {
console.error(
`Triangle fuzz tester error! Copy and paste the following line into the test suite:
${formatActionsPerRoot(actions)}
`,
);
throw e;
}
}
});
});
});