Skip to content

Commit cc1997c

Browse files
committed
Create visual test system
1 parent 0c174c6 commit cc1997c

File tree

16 files changed

+488
-0
lines changed

16 files changed

+488
-0
lines changed

contributor_docs/unit_testing.md

+21
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,24 @@ test('keyIsPressed is a boolean', function() {
118118
119119
Similarly we can use `assert.strictEqual(myp5.keyIsPressed, true)` to assert if the value is true. You can read more about chai's assert [here](https://www.chaijs.com/api/assert/)
120120
Now that you have written the tests, run them and see if the method behaves as expected. If not, create an issue for the same and if you want, you can even work on fixing it!
121+
122+
## Visual tests
123+
124+
Visual tests are a way to make sure sketches do not unexpectedly change when we change the implementation of p5.js features. Each visual test file lives in the `test/unit/visual/cases` folder. Inside each file there are multiple visual test cases. Each case creates a sample sketch, and then calls `screenshot()` to check how the sketch looks.
125+
126+
```js
127+
visualTest('2D objects maintain correct size', function(p5, screenshot) {
128+
p5.createCanvas(50, 50, p5.WEBGL);
129+
p5.noStroke();
130+
p5.fill('red');
131+
p5.rectMode(p5.CENTER);
132+
p5.rect(0, 0, p5.width/2, p5.height/2);
133+
screenshot();
134+
});
135+
```
136+
137+
If you need to add a new test file, add it to that folder, then add the filename to the list in `test/visual/visualTestList.js`. Additionally, if you want that file to be run automatically as part of continuous integration on every pull request, add the filename to the `visual` list in `test/unit/spec.js`.
138+
139+
When you add a new test, running `npm test` will generate new screenshots for any visual tests that do not yet have them. Those screenshots will then be used as a reference the next time tests run to make sure the sketch looks the same. If a test intentionally needs to look different, you can delete the folder matching the test name in the `test/unit/visual/screenshots` folder, and then re-run `npm test` to generate a new one.
140+
141+
To manually inspect all visual tests, run `grunt yui:dev` to launch a local server, then go to http://127.0.0.1:9001/test/visual.html to see a list of all test cases.

tasks/test/mocha-chrome.js

+23
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const puppeteer = require('puppeteer');
44
const util = require('util');
55
const mapSeries = require('promise-map-series');
66
const fs = require('fs');
7+
const path = require('path');
78
const EventEmitter = require('events');
89

910
const mkdir = util.promisify(fs.mkdir);
@@ -28,6 +29,28 @@ module.exports = function(grunt) {
2829
const page = await browser.newPage();
2930

3031
try {
32+
// Set up visual tests
33+
await page.evaluateOnNewDocument(function(shouldGenerateScreenshots) {
34+
window.shouldGenerateScreenshots = shouldGenerateScreenshots;
35+
}, !process.env.CI);
36+
37+
await page.exposeFunction('writeImageFile', function(filename, base64Data) {
38+
fs.mkdirSync('test/' + path.dirname(filename), { recursive: true });
39+
const prefix = /^data:image\/\w+;base64,/;
40+
fs.writeFileSync(
41+
'test/' + filename,
42+
base64Data.replace(prefix, ''),
43+
'base64'
44+
);
45+
});
46+
await page.exposeFunction('writeFile', function(filename, data) {
47+
fs.mkdirSync('test/' + path.dirname(filename), { recursive: true });
48+
fs.writeFileSync(
49+
'test/' + filename,
50+
data
51+
);
52+
});
53+
3154
// Using eval to start the test in the browser
3255
// A 'mocha:end' event will be triggered with test runner end
3356
await page.evaluateOnNewDocument(`

test/unit/spec.js

+8
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,16 @@ var spec = {
4747
'p5.Shader',
4848
'p5.Texture',
4949
'light'
50+
],
51+
'visual/cases': [
52+
// Add the visual tests that you want run as part of CI here. Feel free
53+
// to omit some for speed if they should only be run manually.
54+
'webgl'
5055
]
5156
};
57+
document.write(
58+
'<script src="unit/visual/visualTest.js" type="text/javascript"></script>'
59+
);
5260
Object.keys(spec).map(function(folder) {
5361
spec[folder].map(function(file) {
5462
var string = [

test/unit/visual/cases/webgl.js

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
visualSuite('WebGL', function() {
2+
visualSuite('Camera', function() {
3+
visualTest('2D objects maintain correct size', function(p5, screenshot) {
4+
p5.createCanvas(50, 50, p5.WEBGL);
5+
p5.noStroke();
6+
p5.fill('red');
7+
p5.rectMode(p5.CENTER);
8+
p5.rect(0, 0, p5.width/2, p5.height/2);
9+
screenshot();
10+
});
11+
12+
visualTest('Custom camera before and after resize', function(p5, screenshot) {
13+
p5.createCanvas(25, 50, p5.WEBGL);
14+
const cam = p5.createCamera();
15+
p5.setCamera(cam);
16+
cam.setPosition(-10, -10, 800);
17+
p5.strokeWeight(4);
18+
p5.box(20);
19+
screenshot();
20+
21+
p5.resizeCanvas(50, 25);
22+
p5.box(20);
23+
screenshot();
24+
});
25+
});
26+
27+
visualSuite('Lights', function() {
28+
visualTest('Fill color and default ambient material', function(p5, screenshot) {
29+
p5.createCanvas(50, 50, p5.WEBGL);
30+
p5.noStroke();
31+
p5.lights();
32+
p5.fill('red');
33+
p5.sphere(20);
34+
screenshot();
35+
});
36+
});
37+
});
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"numScreenshots": 1
3+
}
Loading
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"numScreenshots": 2
3+
}
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"numScreenshots": 1
3+
}

test/unit/visual/visualTest.js

+196
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* A helper class to contain an error and also the screenshot data that
3+
* caused the error.
4+
*/
5+
class ScreenshotError extends Error {
6+
constructor(message, actual, expected) {
7+
super(message);
8+
this.actual = actual;
9+
this.expected = expected;
10+
}
11+
}
12+
13+
function toBase64(img) {
14+
return img.canvas.toDataURL();
15+
}
16+
17+
function escapeName(name) {
18+
// Encode slashes as `encodeURIComponent('/')`
19+
return name.replace(/\//g, '%2F');
20+
}
21+
22+
let namePrefix = '';
23+
24+
/**
25+
* A helper to define a category of visual tests.
26+
*
27+
* @param name The name of the category of test.
28+
* @param callback A callback that calls `visualTest` a number of times to define
29+
* visual tests within this suite.
30+
* @param [options] An options object with optional additional settings. Set its
31+
* key `focus` to true to only run this test, or its `skip` key to skip it.
32+
*/
33+
window.visualSuite = function(
34+
name,
35+
callback,
36+
{ focus = false, skip = false } = {}
37+
) {
38+
const lastPrefix = namePrefix;
39+
namePrefix += escapeName(name) + '/';
40+
41+
let suiteFn = suite;
42+
if (focus) {
43+
suiteFn = suiteFn.only;
44+
}
45+
if (skip) {
46+
suiteFn = suiteFn.skip;
47+
}
48+
suiteFn(name, callback);
49+
50+
namePrefix = lastPrefix;
51+
};
52+
53+
window.checkMatch = function(actual, expected, p5) {
54+
const maxSide = 50;
55+
const scale = Math.min(maxSide/expected.width, maxSide/expected.height);
56+
for (const img of [actual, expected]) {
57+
img.resize(
58+
Math.ceil(img.width * scale),
59+
Math.ceil(img.height * scale)
60+
);
61+
}
62+
const diff = p5.createImage(actual.width, actual.height);
63+
diff.drawingContext.drawImage(actual.canvas, 0, 0);
64+
diff.drawingContext.globalCompositeOperation = 'difference';
65+
diff.drawingContext.drawImage(expected.canvas, 0, 0);
66+
diff.filter(p5.ERODE, false);
67+
diff.loadPixels();
68+
69+
let ok = true;
70+
for (let i = 0; i < diff.pixels.length; i++) {
71+
if (i % 4 === 3) continue; // Skip alpha checks
72+
if (Math.abs(diff.pixels[i]) > 10) {
73+
ok = false;
74+
break;
75+
}
76+
}
77+
return { ok, diff };
78+
};
79+
80+
/**
81+
* A helper to define a visual test, where we will assert that a sketch matches
82+
* screenshots saved ahead of time of what the test should look like.
83+
*
84+
* When defining a new test, run the tests once to generate initial screenshots.
85+
*
86+
* To regenerate screenshots for a test, delete its screenshots folder in
87+
* the test/unit/visual/screenshots directory, and rerun the tests.
88+
*
89+
* @param testName The display name of a test. This also links the test to its
90+
* expected screenshot, so make sure to rename the screenshot folder after
91+
* renaming a test.
92+
* @param callback A callback to set up the test case. It takes two parameters:
93+
* first is `p5`, a reference to the p5 instance, and second is `screenshot`, a
94+
* function to grab a screenshot of the canvas. It returns either nothing, or a
95+
* Promise that resolves when all screenshots have been taken.
96+
* @param [options] An options object with optional additional settings. Set its
97+
* key `focus` to true to only run this test, or its `skip` key to skip it.
98+
*/
99+
window.visualTest = function(
100+
testName,
101+
callback,
102+
{ focus = false, skip = false } = {}
103+
) {
104+
const name = namePrefix + escapeName(testName);
105+
let suiteFn = suite;
106+
if (focus) {
107+
suiteFn = suiteFn.only;
108+
}
109+
if (skip) {
110+
suiteFn = suiteFn.skip;
111+
}
112+
113+
suiteFn(testName, function() {
114+
let myp5;
115+
116+
setup(function() {
117+
return new Promise(res => {
118+
myp5 = new p5(function(p) {
119+
p.setup = function() {
120+
res();
121+
};
122+
});
123+
});
124+
});
125+
126+
teardown(function() {
127+
myp5.remove();
128+
});
129+
130+
test('matches expected screenshots', async function() {
131+
let expectedScreenshots;
132+
try {
133+
metadata = await fetch(
134+
`unit/visual/screenshots/${name}/metadata.json`
135+
).then(res => res.json());
136+
expectedScreenshots = metadata.numScreenshots;
137+
} catch (e) {
138+
expectedScreenshots = 0;
139+
}
140+
141+
if (!window.shouldGenerateScreenshots && !expectedScreenshots) {
142+
// If running on CI, all expected screenshots should already
143+
// be generated
144+
throw new Error('No expected screenshots found');
145+
}
146+
147+
const actual = [];
148+
149+
// Generate screenshots
150+
await callback(myp5, () => {
151+
actual.push(myp5.get());
152+
});
153+
154+
if (expectedScreenshots && actual.length !== expectedScreenshots) {
155+
throw new Error(
156+
`Expected ${expectedScreenshots} screenshot(s) but generated ${actual.length}`
157+
);
158+
}
159+
if (!expectedScreenshots) {
160+
writeFile(
161+
`unit/visual/screenshots/${name}/metadata.json`,
162+
JSON.stringify({ numScreenshots: actual.length }, null, 2)
163+
);
164+
}
165+
166+
const expectedFilenames = actual.map(
167+
(_, i) => `unit/visual/screenshots/${name}/${i.toString().padStart(3, '0')}.png`
168+
);
169+
const expected = expectedScreenshots
170+
? (
171+
await Promise.all(
172+
expectedFilenames.map(path => new Promise((resolve, reject) => {
173+
myp5.loadImage(path, resolve, reject);
174+
}))
175+
)
176+
)
177+
: [];
178+
179+
for (let i = 0; i < actual.length; i++) {
180+
if (expected[i]) {
181+
if (!checkMatch(actual[i], expected[i], myp5).ok) {
182+
throw new ScreenshotError(
183+
`Screenshots do not match! Expected:\n${toBase64(expected[i])}\n\nReceived:\n${toBase64(actual[i])}\n\n` +
184+
'If this is unexpected, paste these URLs into your browser to inspect them, or run grunt yui:dev and go to http://127.0.0.1:9001/test/visual.html.\n\n' +
185+
`If this change is expected, please delete the test/unit/visual/screenshots/${name} folder and run tests again to generate a new screenshot.`,
186+
actual[i],
187+
expected[i]
188+
);
189+
}
190+
} else {
191+
writeImageFile(expectedFilenames[i], toBase64(actual[i]));
192+
}
193+
}
194+
});
195+
});
196+
};

test/visual.html

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
5+
<title>p5.js Visual Test Runner</title>
6+
<link rel="stylesheet" type="text/css" href="visual/style.css" />
7+
</head>
8+
<body>
9+
<h1>p5.js Visual Test Runner</h1>
10+
<p id="metrics"></p>
11+
<script src="../../lib/p5.js" type="text/javascript"></script>
12+
<script src="unit/visual/visualTest.js" type="text/javascript"></script>
13+
<script src="visual/visualTestRunner.js" type="text/javascript"></script>
14+
<script src="visual/visualTestList.js" type="text/javascript"></script>
15+
</body>
16+
</html>

test/visual/style.css

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
body {
2+
font-family: sans-serif;
3+
}
4+
5+
h4 {
6+
font-weight: normal;
7+
margin-bottom: 10px;
8+
margin-top: 0;
9+
}
10+
11+
#metrics {
12+
color: #777;
13+
text-decoration: italic;
14+
}
15+
16+
.suite {
17+
padding-left: 10px;
18+
border-left: 2px solid rgba(0,0,0,0.2);
19+
margin-bottom: 30px;
20+
}
21+
.skipped {
22+
opacity: 0.5;
23+
}
24+
.suite.focused {
25+
border-left-color: #2B2;
26+
}
27+
.suite.failed {
28+
border-left-color: #F00;
29+
}
30+
31+
.failed {
32+
color: #F00;
33+
}
34+
35+
.screenshot img {
36+
border: 2px solid #000;
37+
margin-right: 5px;
38+
}
39+
.screenshot.failed img {
40+
border-color: #F00;
41+
}
42+
43+
.diff {
44+
background: #000;
45+
}

0 commit comments

Comments
 (0)