diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eef484..62460d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [1.2.0] + +### Breaking Changes + +- `stop()` now pauses the sketch (`noLoop()`); call `run()` to resume +- `remove()` / `clear()` fully tears down the p5 instance and frees resources + +### New Features + +- `loadImage()` — local files and HTTP URLs, works in `preload` with `image()` in draw +- `loadJSON()` — local and remote JSON files +- `loadStrings()` — local text files as array of lines +- `loadTable()` — CSV/TSV/SSV files as `p5.Table` with optional header parsing +- `drawingContext` — direct Canvas 2D API access after `createCanvas()` +- Math constants and functions (`PI`, `TWO_PI`, `abs`, `sin`, `cos`, etc.) explicitly bound +- p5.js constants (`CORNER`, `CENTER`, `RGB`, `HSB`, blend modes, key codes, etc.) explicitly bound +- Accessibility, save, audio, and input event functions stubbed to prevent crashes + +### Performance + +- ~2× faster frame reads when sketch canvas dimensions match p5b output dimensions +- Fixed memory leak in `stop()`/`run()` cycles by reusing the sketch canvas + +### Bug Fixes + +- Fixed p5.js initialization crashes (`parentNode`, `mediaDevices`, `navigator`) +- `loadFont()` now throws a descriptive error on missing files +- WEBGL mode now throws a clear unsupported error instead of crashing + ## [1.1.1] - Fix unbounded memory leak when running sketches diff --git a/OPEN_ISSUES.md b/OPEN_ISSUES.md new file mode 100644 index 0000000..7486edc --- /dev/null +++ b/OPEN_ISSUES.md @@ -0,0 +1,142 @@ +# Open Issues - p5b.js + +## Completed & Tested + +The following are implemented and have passing test coverage: + +- **Math Constants**: PI, TWO_PI, HALF_PI, QUARTER_PI, TAU, DEGREES, RADIANS +- **Math Functions**: abs, ceil, floor, round, pow, sqrt, exp, log, max, min, sin, cos, tan, asin, acos, atan, atan2, sq, mag, fract +- **Random/Noise**: random, randomSeed, randomGaussian, noise, noiseSeed, noiseDetail +- **Utility Functions**: map, lerp, lerpColor, constrain, dist, createVector +- **String Functions**: nf, nfc, nfp, nfs, join, split, splitTokens, trim +- **Shape Constants**: CORNER, CORNERS, RADIUS, CENTER, LEFT, RIGHT, TOP, BOTTOM, BASELINE, CLOSE, OPEN, CHORD, PIE, POINTS, LINES, TRIANGLES, etc. +- **Blend Mode Constants**: BLEND, ADD, REMOVE, DARKEST, LIGHTEST, DIFFERENCE, SUBTRACT, EXCLUSION, MULTIPLY, SCREEN, REPLACE, OVERLAY, HARD_LIGHT, SOFT_LIGHT, DODGE, BURN +- **Cursor/Input Constants**: ARROW, CROSS, HAND, MOVE, TEXT, WAIT, key codes, arrow keys +- **Typography Constants**: NORMAL, ITALIC, BOLD, BOLDITALIC, CHAR, WORD +- **Other Constants**: AUTO, STROKE, FILL, TEXTURE, IMMEDIATE, NEAREST, REPEAT, CLAMP, MIRROR, FLAT, SMOOTH, LANDSCAPE, PORTRAIT +- **Time Functions**: year(), month(), day(), hour(), minute(), second() +- **Environment**: frameRate(), loop(), noLoop(), isLooping(), redraw() +- **Loop lifecycle**: noLoop() in setup, noLoop() in draw, redraw() while stopped, loop()/noLoop() toggle, external control, frameCount preservation +- **Mode/Style**: rectMode(), ellipseMode(), strokeCap(), strokeJoin() +- **Typography**: textLeading(), textStyle(), textWidth(), textAlign(), textWrap() +- **Data/IO**: loadStrings(), loadTable() +- **Environment (Extended)**: cursor(), noCursor(), pixelDensity(), windowWidth, windowHeight +- **Accessibility**: describe(), describeElement(), textOutput(), gridOutput() (all noops in headless) +- **imageMode()**: CORNER, CENTER, CORNERS +- **drawingContext**: direct Canvas 2D API access after createCanvas() + +--- + +## Next Up (v1.3.0) + +These are the top priorities for the next release. + +### 1. Graphics Pool State on Reuse + +Pooled graphics retain previous state (draw settings, transformations, pixel data) between frames. + +**Location:** `p5b.js` — `createGraphics` pool checkout (~line 310) + +**Fix:** Reset graphics state (transform, fill, stroke, etc.) when pulling from pool. + +--- + +### 2. Graphics Pool Unbounded Growth + +If a sketch creates graphics of many different sizes, the pool map grows indefinitely. + +**Location:** `p5b.js` — pool management in `_initSketch` (~line 185) + +**Fix:** Cap bucket size per key, or add LRU eviction across the pool map. + +### 3. `global:` Config Option + +Shared sketch-scope variables across `preload`/`setup`/`draw` when using inline config functions (no `sketchPath`). + +**Root cause:** When config supplies `{preload, setup, draw}` as functions, p5b assigns each to `global.*`. These functions are defined in the caller's closure — a `let x` inside `preload` is invisible to `setup`. Users must write `global.x = ...` explicitly to share state across lifecycle functions. + +By contrast, `sketchPath` sketches run via `vm.runInThisContext`, so top-level variables in the sketch file are shared naturally. + +**Fix:** Add a `global:` function to config that runs before `preload` and declares shared variables into global scope: + +```js +new P5b({ + global: () => { myImage = null; }, + preload: () => { myImage = loadImage('img.png'); }, + setup: () => { image(myImage, 0, 0); }, + draw: () => {}, +}); +``` + +--- + +## Backlog + +Lower priority issues identified during code review. Not scoped to any specific release. + +### Code Quality + +#### Asset Path / URL Duplication +`filePath.startsWith("http")` and `file://` URL construction duplicated across `loadImage`, `loadJSON`, `loadStrings`, `loadTable`. Extract to a shared helper. + +#### Preload Counter Duplication +`p5._incrementPreload()` / `setImmediate(p5._decrementPreload())` pattern repeated across `loadImage`, `loadStrings`, `loadTable`. Extract to a helper. + +#### `fetch` Bound at Init Time +`p5b-dom.js` sets `fetch: global.fetch` at construction time. If `fetch` isn't available yet (Node < 18 without polyfill), it's permanently `undefined`. Should be a getter: `get fetch() { return global.fetch; }`. + +#### `loadFont()` vs `loadJSON()` Inconsistency +`loadFont()` is synchronous (blocking file I/O). `loadJSON()` is async. Surprising difference for users familiar with p5.js where both use the same callback/preload pattern. + +#### `async preload()` Silently Broken +If a sketch uses `async function preload() { await loadJSON(...) }`, p5.js never awaits the returned promise. Assets will not be loaded before `setup()` runs. Should detect and warn. + +### API Gaps + +#### `loadJSON()` Callback Compatibility +p5.js `loadJSON()` supports `loadJSON(path, successCallback, errorCallback)`. p5b's implementation is async-only. Sketches using the callback pattern will silently get no data. + +#### `loadStrings()` HTTP Support +`loadStrings()` supports local files only. `loadImage()` and `loadJSON()` both support HTTP URLs. Inconsistent. + +#### `loadBytes()` Missing +`loadBytes()` is not implemented. Calls will throw `"loadBytes is not defined"` with no helpful error. + +#### `loadXML()` Missing +`loadXML()` is not implemented. Calls will throw `"loadXML is not defined"` with no helpful error. + +#### DOM Functions Behavior Unverified +p5.js may auto-bind DOM functions (`createButton()`, `createCheckbox()`, `createRadio()`, `createSlider()`, `createColorPicker()`, `createInput()`, `createFileInput()`, `createSelect()`, `createDiv()`, `createP()`, `createSpan()`, `createImg()`, `createA()`, `createVideo()`, `createCapture()`, `createTextarea()`) via `_bindGlobals()`. Their actual behavior in headless has not been tested. Need to audit what p5.js exposes and whether calls succeed, silently fail, or crash. + +#### `select()`, `selectAll()`, `removeElements()` Not Implemented +These query and manipulate p5-created DOM elements. In headless, all elements live in the DOM shim — these functions should query/manipulate the shim's tracked elements rather than a real browser DOM. Non-trivial to implement correctly. + +--- + +## Known Unsupported (By Design) + +These require browser APIs unavailable in Node.js: + +### Sound (p5.sound) + +| Missing | +|---------| +| `loadSound`, `loadAudio`, `createAudio` | +| `Oscillator`, `p5.AudioIn`, `p5.FFT`, `p5.Amplitude` | +| `play`, `pause`, `loop`, `stop`, `jump`, `rate`, `amp` | + +### Video/Capture + +| Missing | +|---------| +| `createCapture(VIDEO/AUDIO)`, `createVideo()` | + +### 3D/WebGL + +WEBGL renderer throws by design. + +| Missing | +|---------| +| `createCanvas(w, h, WEBGL)` | +| `plane`, `box`, `sphere`, `cylinder`, `cone`, `torus` | +| `loadModel`, `ambientLight`, `directionalLight`, `camera`, `orbitControl` | diff --git a/README.md b/README.md index d8b8c94..9837821 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ NOTE: several features are untested or unsupported, including the following: - p5.js v2.x - webgl - shaders -- images - video - sound - third party plugins or extensions @@ -65,22 +64,30 @@ Creates a new P5b instance with the given options. #### `run()` -Start sketch execution and begin rendering frames. +Start or resume sketch execution. On first call, initializes the p5 instance. After `stop()`, resumes the draw loop. Throws if called after `remove()`. ```javascript p5b.run(); ``` -Throws if already running. - #### `stop()` -Stop sketch execution and clean up resources. +Pause sketch execution. The p5 instance and canvas are kept alive. Call `run()` to resume. ```javascript p5b.stop(); ``` +#### `remove()` + +Fully tear down the p5 instance and free all resources. Calling `run()` after `remove()` throws. + +```javascript +p5b.remove(); // or p5b.clear() +``` + +`clear()` is an alias for `remove()`. + #### `toFrame()` Get current canvas as a Uint8Array RGBA buffer. @@ -163,6 +170,38 @@ const [r, g, b, a] = buffer.slice(idx, idx + 4); - Reducing `width` / `height` - Optimizing `draw()` logic +### Happy Path Optimization + +When your sketch calls `createCanvas(w, h)` with dimensions that exactly match the p5b `width` and `height` config, p5b reads pixels directly from the canvas without any resizing step. This is ~2× faster per frame. + +```javascript +// Fast: canvas matches p5b output dimensions — no resize +const p5b = new P5b({ width: 512, height: 512, ... }); +// In sketch: createCanvas(512, 512) + +// Slower: canvas is larger than p5b output — resized every frame +const p5b = new P5b({ width: 256, height: 256, ... }); +// In sketch: createCanvas(512, 512) +``` + +### Browser Preview (p5.js Web Editor) + +p5b sets `navigator.userAgent` to `"p5b-dom/"` so sketches can detect the headless environment. Use this to scale up the canvas for a readable preview when running in the browser, while keeping the output dimensions small for p5b: + +```javascript +function setup() { + createCanvas(64, 64); + if (!navigator.userAgent.includes('p5b')) { + resizeCanvas( + floor(min(windowWidth, windowHeight) / width) * width, + floor(min(windowWidth, windowHeight) / height) * height + ); + } +} +``` + +This scales the canvas to the largest integer multiple that fits the window — no CSS, no interpolation artifacts. + ## Transport Layer For streaming frames to external systems, see [examples/ex-p5b-zmq.js](examples/ex-p5b-zmq.js) for a ZeroMQ adapter reference. diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..2160109 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test-preload.js"] \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 9f8bbc2..1a94753 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,7 +15,14 @@ module.exports = [ clearImmediate: "readonly", Buffer: "readonly", setInterval: "readonly", - clearInterval: "readonly" + clearInterval: "readonly", + setTimeout: "readonly", + createCanvas: "readonly", + background: "readonly", + createGraphics: "readonly", + fill: "readonly", + noStroke: "readonly", + rect: "readonly" } }, rules: { @@ -50,9 +57,26 @@ module.exports = [ height: "readonly", createGraphics: "readonly", loadFont: "readonly", + loadJSON: "readonly", noStroke: "readonly", ellipse: "readonly", - image: "readonly" + image: "readonly", + saveCanvas: "readonly", + saveJSON: "readonly", + print: "readonly", + mouseX: "readonly", + mouseY: "readonly", + key: "readonly", + keyCode: "readonly", + mousePressed: "readonly", + keyPressed: "readonly", + touchStarted: "readonly", + accelerationX: "readonly", + accelerationY: "readonly", + accelerationZ: "readonly", + loadImage: "readonly", + noLoop: "readonly", + path: "readonly" } }, rules: { @@ -79,9 +103,24 @@ module.exports = [ height: "readonly", createGraphics: "readonly", loadFont: "readonly", + loadJSON: "readonly", noStroke: "readonly", ellipse: "readonly", - image: "readonly" + image: "readonly", + saveCanvas: "readonly", + saveJSON: "readonly", + print: "readonly", + mouseX: "readonly", + mouseY: "readonly", + key: "readonly", + keyCode: "readonly", + mousePressed: "readonly", + keyPressed: "readonly", + touchStarted: "readonly", + accelerationX: "readonly", + accelerationY: "readonly", + accelerationZ: "readonly", + loadImage: "readonly" } }, rules: { diff --git a/p5b-dom.js b/p5b-dom.js index c2dc345..2520782 100644 --- a/p5b-dom.js +++ b/p5b-dom.js @@ -1,4 +1,5 @@ const canvas = require("canvas"); +const { version } = require("./package.json"); const noop = () => {}; const spliceFrom = (arr, item) => { @@ -29,10 +30,22 @@ class P5bDOM { this._canvases.length = 0; } + resize(newWidth, newHeight) { + this.width = newWidth; + this.height = newHeight; + this._bodyChildren.length = 0; + this._canvases.length = 0; + this._init(); + } + _init() { const bodyChildren = this._bodyChildren; const allCanvases = this._canvases; + // Absorbs unguarded el.parentNode.removeChild(el) calls from p5.js internals + // when an element has no real parent (matches silent browser behavior). + const detachedParent = { removeChild: noop, appendChild: noop }; + const makeStubElement = (tag) => { const el = { tagName: tag.toUpperCase(), @@ -51,7 +64,7 @@ class P5bDOM { setAttribute: noop, getAttribute: () => null, getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0 }), - parentNode: null, + parentNode: detachedParent, childNodes: [], children: [], innerHTML: "", @@ -70,7 +83,7 @@ class P5bDOM { c.removeEventListener = noop; c.dispatchEvent = () => true; c.getBoundingClientRect = () => ({ left: 0, top: 0, width: c.width, height: c.height, right: c.width, bottom: c.height }); - c.parentNode = null; + c.parentNode = detachedParent; c.style = {}; allCanvases.push(c); return c; @@ -86,7 +99,7 @@ class P5bDOM { appendChild: (el) => { bodyChildren.push(el); if (el && typeof el === "object") el.parentNode = document.body; return el; }, removeChild: (el) => { spliceFrom(bodyChildren, el); - if (el && typeof el === "object") el.parentNode = null; + if (el && typeof el === "object") el.parentNode = detachedParent; spliceFrom(allCanvases, el); return el; }, @@ -129,7 +142,7 @@ class P5bDOM { const win = { document, screen: { width: this.width, height: this.height }, - navigator: { userAgent: "Node.js", languages: ["en"], language: "en", userLanguage: "en", mediaDevices: null }, + navigator: { userAgent: `p5b-dom/${version}`, languages: ["en"], language: "en", userLanguage: "en", mediaDevices: { getUserMedia: () => Promise.reject(new Error("getUserMedia not supported in headless")) } }, addEventListener: noop, removeEventListener: noop, dispatchEvent: () => true, @@ -146,15 +159,19 @@ class P5bDOM { HTMLCanvasElement: canvas.Canvas, ImageData: canvas.ImageData, performance: { now: () => Date.now() }, + fetch: global.fetch, }; global.window = win; global.document = document; global.screen = win.screen; - if (!Object.getOwnPropertyDescriptor(global, "navigator")) { + + const navDesc = Object.getOwnPropertyDescriptor(global, "navigator"); + if (!navDesc || navDesc.configurable) { Object.defineProperty(global, "navigator", { get: () => global.window.navigator, - configurable: true + configurable: true, + enumerable: true, }); } global.HTMLCanvasElement = canvas.Canvas; diff --git a/p5b.js b/p5b.js index 2ef2766..39be494 100644 --- a/p5b.js +++ b/p5b.js @@ -8,6 +8,18 @@ const { P5bDOM } = require("./p5b-dom"); const noop = () => {}; +// Swap pixel data order BGRA -> RGBA +const reorderBuffer = (buf) => { + const ret = new Uint8Array(buf); + for (let i = 0; i < ret.length; i += 4) { + const b = ret[i]; + ret[i] = ret[i + 2]; + ret[i + 2] = b; + } + return ret; +}; + +// TODO: function? for any global functions to exec outside of preload/setup/draw? const P5B_DEFAULTS = { sketchPath: null, width: 32, @@ -26,6 +38,8 @@ class P5b extends EventEmitter { this._destCanvas = null; this._gfxPool = new Map(); this._gfxActive = []; + this._redrawing = false; + this._removed = false; this._metrics = { framesDrawn: 0, errors: 0 @@ -35,51 +49,87 @@ class P5b extends EventEmitter { } run() { + if (this._removed) { + throw new Error("P5b instance has been removed. Create a new instance to run again."); + } + + // Resume after stop() if (this._myP5) { - throw new Error("P5b is already running. Call stop() before run()."); + this._myP5.loop(); + this._myP5.redraw(); + return; } + // First run const sketch = (pInstance) => { this._myP5 = pInstance; this._bindGlobals(); this._initSketch(); }; - new (this._loadP5())(sketch); + try { + new (this._loadP5())(sketch); + } catch (error) { + this._myP5 = null; + this._emitRuntimeError(error, "setup"); + this._dom.clear(); + } } stop() { + this._myP5?.noLoop(); + } + + remove() { this._myP5?.remove(); this._myP5 = null; this._destCanvas = null; this._dom.clear(); this._gfxPool.clear(); this._gfxActive = []; + this._removed = true; + } + + clear() { + this.remove(); } toFrame() { - const srcCanvas = this._dom.getCanvas(); + const srcCanvas = this._myP5?.canvas; if (!srcCanvas) { throw new Error("Canvas not initialized. Call run() first."); } - if (!this._destCanvas) { + // Happy path: canvas dimensions match p5b config — skip drawImage blit. + // node-canvas getImageData() (used by loadPixels) already returns RGBA, no swap needed. + if (srcCanvas.width === this.width && srcCanvas.height === this.height) { + this._myP5.loadPixels(); + return new Uint8Array(this._myP5.pixels.buffer); + } + + // Canvas resizing only happens if sketch code manually resizes, + // the performance and memory impact here should be negligible if not zero. + if (!this._destCanvas || this._destCanvas.width !== this.width || this._destCanvas.height !== this.height) { this._destCanvas = canvas.createCanvas(this.width, this.height); } - this._destCanvas.getContext("2d").drawImage(srcCanvas, 0, 0, srcCanvas.width, srcCanvas.height, 0, 0, this.width, this.height); + const ctx = this._destCanvas.getContext("2d"); - const ret = new Uint8Array(this._destCanvas.toBuffer("raw")); + // Ensure a blank canvas on all pixels when not stretching source frame + ctx.clearRect(0, 0, this.width, this.height); - // Swap pixel data order BGRA -> RGBA - for (let i = 0; i < ret.length; i += 4) { - const swapR2B = ret[i]; - const swapB2R = ret[i + 2]; - ret[i] = swapB2R; - ret[i + 2] = swapR2B; - } + // Fit to destination, do not stretch + const xRatio = this.width / srcCanvas.width; + const yRatio = this.height / srcCanvas.height; + const scaleFactor = Math.min(xRatio, yRatio); - return ret; + ctx.drawImage( + srcCanvas, + 0, 0, srcCanvas.width, srcCanvas.height, + 0, 0, srcCanvas.width * scaleFactor, srcCanvas.height * scaleFactor + ); + + return reorderBuffer(this._destCanvas.toBuffer("raw")); } getMetrics() { @@ -87,6 +137,9 @@ class P5b extends EventEmitter { } _loadP5() { + global.performance = { + now: () => Date.now() + }; return require("p5").default || require("p5"); } @@ -111,8 +164,19 @@ class P5b extends EventEmitter { } }; + global.redraw = (...args) => { + this._redrawing = true; + try { this._myP5.redraw(...args); } + finally { this._redrawing = false; } + }; + this._myP5.draw = () => { try { + // Block animation loop calls when stopped, but always allow redraw() through + if (!this._redrawing && this._metrics.framesDrawn > 0 && !this._myP5.isLooping()) { + return; + } + const elemsBefore = this._myP5._elements.length; global.draw(); @@ -151,6 +215,7 @@ class P5b extends EventEmitter { const value = this._myP5[key]; if (typeof value === "function") { global[key] = value.bind(this._myP5); + } else if (!key.startsWith("_")) { // Bind non-private properties (like frameCount, width, height) Object.defineProperty(global, key, { @@ -161,26 +226,92 @@ class P5b extends EventEmitter { } } + global._resolveAssetPath = function(sketchPath, filePath) { + const assetDir = sketchPath + ? path.dirname(path.resolve(sketchPath)) + : process.cwd(); + return path.isAbsolute(filePath) + ? filePath + : path.resolve(assetDir, filePath); + }; + global.loadFont = (function(that) { - const P5Constructor = that._loadP5(); return function(fontPath) { - const assetDir = that.sketchPath - ? path.dirname(path.resolve(that.sketchPath)) - : process.cwd(); - const fontData = fs.readFileSync( - path.isAbsolute(fontPath) - ? fontPath - : path.resolve(assetDir, fontPath) - ); + const resolvedPath = global._resolveAssetPath(that.sketchPath, fontPath); + let fontData; + try { + fontData = fs.readFileSync(resolvedPath); + } catch (error) { + if (error.code === "ENOENT") { + throw new Error(`Failed to load font: file not found at ${resolvedPath}`); + } + throw new Error(`Failed to load font: ${error.message}`); + } const parsedFont = opentype.parse( fontData.buffer.slice(fontData.byteOffset, fontData.byteOffset + fontData.byteLength) ); - const p5Font = new P5Constructor.Font(that._myP5); + const p5Font = new (that._loadP5()).Font(that._myP5); p5Font.font = parsedFont; return p5Font; }; })(this); + // loadImage: mirrors p5.js's original loadImage contract exactly. + // Returns a p5.Image shell synchronously (so img = loadImage(path) works + // in preload and img.width/height are usable in setup/draw after the + // preload counter clears). The shell is backed by a node-canvas Canvas, + // so p5.js's image() function can draw it via img.canvas/.drawingContext. + global.loadImage = (function(that) { + return function(filePath, onSuccess, onError) { + const p5 = that._myP5; + if (!p5) { + throw new Error("P5 instance is broken, did you call p5b.stop()?"); + } + + p5._incrementPreload(); + + const resolvedPath = global._resolveAssetPath(that.sketchPath, filePath); + const url = filePath.startsWith("http") ? filePath : `file://${resolvedPath}`; + let pImg; + + const loadImageData = (imageData) => { + const rawImg = new canvas.Image(); + rawImg.src = Buffer.from(imageData); + pImg = new (that._loadP5()).Image(rawImg.width, rawImg.height); + pImg.drawingContext.drawImage(rawImg, 0, 0); + // Ignoring for now, only needed for webGL to refresh textures + // pImg.modified = true; + if (onSuccess) onSuccess(pImg); + setImmediate(() => p5._decrementPreload()); + }; + + const handleError = (error) => { + setImmediate(() => p5._decrementPreload()); + if (onError) onError(error); + else console.error(`Failed to load image: ${error.message}`); + }; + + if (url.startsWith("file://")) { + try { + const buf = fs.readFileSync(resolvedPath); + loadImageData(buf.buffer); + } catch (error) { + handleError(error); + } + } else { + global.fetch(url) + .then(response => { + if (!response.ok) throw new Error(`Failed to load image: ${response.status} ${response.statusText}`); + return response.arrayBuffer(); + }) + .then(buf => loadImageData(buf)) + .catch(handleError); + } + + return pImg; + }; + })(this); + // Pool-based createGraphics: reuse Graphics objects across frames instead of // allocating new Cairo surfaces every draw call. On first use a new object is // created normally; on subsequent uses the pooled object is returned directly, @@ -205,6 +336,305 @@ class P5b extends EventEmitter { return ret; }; })(this, global.createGraphics); + + global.loadJSON = (function(that) { + return async function(filePath) { + try { + const resolvedPath = global._resolveAssetPath(that.sketchPath, filePath); + // Support both URLs and local file paths + const url = filePath.startsWith("http") ? filePath : `file://${resolvedPath}`; + const response = await global.fetch(url); + if (!response.ok) { + throw new Error(`Failed to load JSON: ${response.status} ${response.statusText}`); + } + return await response.json(); + } catch (error) { + console.error(`Error loading JSON from ${filePath}:`, error.message); + throw error; + } + }; + })(this); + + // p5.js standalone math functions (pass-through to Math) + global.abs = Math.abs; + global.ceil = Math.ceil; + global.floor = Math.floor; + global.round = Math.round; + global.pow = Math.pow; + global.sqrt = Math.sqrt; + global.exp = Math.exp; + global.log = Math.log; + global.max = Math.max; + global.min = Math.min; + global.sin = Math.sin; + global.cos = Math.cos; + global.tan = Math.tan; + global.asin = Math.asin; + global.acos = Math.acos; + global.atan = Math.atan; + global.atan2 = Math.atan2; + global.PI = Math.PI; + global.TWO_PI = Math.PI * 2; + global.HALF_PI = Math.PI / 2; + global.QUARTER_PI = Math.PI / 4; + global.TAU = Math.PI * 2; + + // p5.js constants + global.DEGREES = "degrees"; + global.RADIANS = "radians"; + global.P2D = "p2d"; + global.WEBGL = "webgl"; + global.WEBGL2 = "webgl2"; + global.CORNER = "corner"; + global.CORNERS = "corners"; + global.RADIUS = "radius"; + global.CENTER = "center"; + global.LEFT = "left"; + global.RIGHT = "right"; + global.TOP = "top"; + global.BOTTOM = "bottom"; + global.BASELINE = "alphabetic"; + global.CLOSE = "close"; + global.OPEN = "open"; + global.CHORD = "chord"; + global.PIE = "pie"; + global.ROUND = "round"; + global.SQUARE = "butt"; + global.PROJECT = "square"; + global.BEVEL = "bevel"; + global.MITER = "miter"; + global.POINTS = 0x0000; + global.LINES = 0x0001; + global.LINE_STRIP = 0x0003; + global.LINE_LOOP = 0x0002; + global.TRIANGLES = 0x0004; + global.TRIANGLE_FAN = 0x0006; + global.TRIANGLE_STRIP = 0x0005; + global.QUADS = "quads"; + global.QUAD_STRIP = "quad_strip"; + global.TESS = "tess"; + global.LINEAR = "linear"; + global.QUADRATIC = "quadratic"; + global.BEZIER = "bezier"; + global.CURVE = "curve"; + global.RGB = "rgb"; + global.HSB = "hsb"; + global.HSL = "hsl"; + global.BLEND = "source-over"; + global.REMOVE = "destination-out"; + global.ADD = "lighter"; + global.DARKEST = "darken"; + global.LIGHTEST = "lighten"; + global.DIFFERENCE = "difference"; + global.SUBTRACT = "subtract"; + global.EXCLUSION = "exclusion"; + global.MULTIPLY = "multiply"; + global.SCREEN = "screen"; + global.REPLACE = "copy"; + global.OVERLAY = "overlay"; + global.HARD_LIGHT = "hard-light"; + global.SOFT_LIGHT = "soft-light"; + global.DODGE = "color-dodge"; + global.BURN = "color-burn"; + global.ARROW = "default"; + global.CROSS = "crosshair"; + global.HAND = "pointer"; + global.MOVE = "move"; + global.TEXT = "text"; + global.WAIT = "wait"; + global.ALT = 18; + global.CONTROL = 17; + global.SHIFT = 16; + global.OPTION = 18; + global.BACKSPACE = 8; + global.DELETE = 46; + global.TAB = 9; + global.ENTER = 13; + global.RETURN = 13; + global.ESCAPE = 27; + global.UP_ARROW = 38; + global.DOWN_ARROW = 40; + global.LEFT_ARROW = 37; + global.RIGHT_ARROW = 39; + global.NORMAL = "normal"; + global.ITALIC = "italic"; + global.BOLD = "bold"; + global.BOLDITALIC = "bold italic"; + global.CHAR = "CHAR"; + global.WORD = "WORD"; + global.AUTO = "auto"; + global.STROKE = "stroke"; + global.FILL = "fill"; + global.TEXTURE = "texture"; + global.IMMEDIATE = "immediate"; + global.NEAREST = "nearest"; + global.REPEAT = "repeat"; + global.CLAMP = "clamp"; + global.MIRROR = "mirror"; + global.FLAT = "flat"; + global.SMOOTH = "smooth"; + global.LANDSCAPE = "landscape"; + global.PORTRAIT = "portrait"; + + // Accessibility functions - noop in headless environment (no DOM/screen readers) + global.describe = noop; + global.describeElement = noop; + global.textOutput = noop; + global.gridOutput = noop; + + // File I/O functions - noop in headless environment + global.saveCanvas = noop; + global.saveFrames = noop; + global.saveJSON = noop; + global.saveStrings = noop; + global.saveTable = noop; + global.saveImage = noop; + global.print = (msg) => console.log(msg); + + // Mouse/keyboard event handlers - noop in headless environment + global.mousePressed = noop; + global.mouseReleased = noop; + global.mouseMoved = noop; + global.mouseDragged = noop; + global.mouseWheel = noop; + global.keyPressed = noop; + global.keyReleased = noop; + global.touchStarted = noop; + global.touchEnded = noop; + global.touchMoved = noop; + global.cursor = noop; + global.noCursor = noop; + + // Mouse/keyboard properties - all zero in headless + global.mouseX = 0; + global.mouseY = 0; + global.pmouseX = 0; + global.pmouseY = 0; + global.key = ""; + global.keyCode = 0; + global.accelerationX = 0; + global.accelerationY = 0; + global.accelerationZ = 0; + + // Audio functions - noop in headless environment (p5.sound) + global.loadSound = noop; + global.loadAudio = noop; + global.createAudio = noop; + global.getAudioContext = noop; + global.userStartAudio = noop; + global.soundFormats = noop; + + global.windowResized = (function(that, wr) { + return function() { + that._dom.resize(that.width, that.height); + that._destCanvas = canvas.createCanvas(that.width, that.height); + if (typeof that.windowResized === "function") { + that.windowResized(); + } + if (typeof wr === "function") wr(); + }; + })(this, global.windowResized); + + global.createCanvas = (function(that, cc) { + return function(w, h, renderer) { + const r = renderer === undefined ? "" : String(renderer); + if (r.toLowerCase() === "webgl") { + throw new Error("WEBGL mode is not supported in p5b. Use P2D or omit the renderer."); + } + const result = cc(w, h, renderer); + that._myP5.windowWidth = w; + that._myP5.windowHeight = h; + global.drawingContext = that._myP5.drawingContext; + return result; + }; + })(this, global.createCanvas); + + global.loadStrings = (function(that) { + return function(filePath, callback, errorCallback) { + const p5 = that._myP5; + p5._incrementPreload(); + try { + const resolvedPath = global._resolveAssetPath(that.sketchPath, filePath); + const content = fs.readFileSync(resolvedPath, "utf8"); + const lines = content + .replace(/\r\n/g, "\r") + .replace(/\n/g, "\r") + .split(/\r/); + if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + if (callback) callback(lines); + setImmediate(() => p5._decrementPreload()); + return lines; + } catch (error) { + setImmediate(() => p5._decrementPreload()); + if (errorCallback) errorCallback(error); + else console.error(`Failed to load strings: ${error.message}`); + } + }; + })(this); + + global.loadTable = (function(that) { + return function(filePath, ...args) { + const p5 = that._myP5; + p5._incrementPreload(); + + // Parse variadic args: loadTable(path, [options], [header], callback, errorCallback) + let options = ""; + let hasHeader = false; + let callback = null; + let errorCallback = null; + for (const arg of args) { + if (typeof arg === "function") { + if (!callback) callback = arg; + else errorCallback = arg; + } else if (typeof arg === "string") { + if (arg === "header") hasHeader = true; + else options = arg; // "csv", "tsv", "ssv" + } + } + + let separator = ","; + if (options === "tsv") separator = "\t"; + else if (options === "ssv") separator = ";"; + + try { + const resolvedPath = global._resolveAssetPath(that.sketchPath, filePath); + const content = fs.readFileSync(resolvedPath, "utf8"); + const lines = content + .replace(/\r\n/g, "\r") + .replace(/\n/g, "\r") + .split(/\r/) + .filter(l => l.length > 0); + + const P5 = that._loadP5(); + const table = new P5.Table(); + + let startRow = 0; + if (hasHeader && lines.length > 0) { + const headers = lines[0].split(separator); + headers.forEach(h => table.addColumn(h.trim())); + startRow = 1; + } + + for (let i = startRow; i < lines.length; i++) { + const cells = lines[i].split(separator); + // Auto-add columns on first data row when no header was provided + if (table.columns.length === 0) { + cells.forEach((_, j) => table.addColumn(String(j))); + } + const row = table.addRow(); + cells.forEach((cell, j) => row.set(j, cell.trim())); + } + + if (callback) callback(table); + setImmediate(() => p5._decrementPreload()); + return table; + } catch (error) { + setImmediate(() => p5._decrementPreload()); + if (errorCallback) errorCallback(error); + else console.error(`Failed to load table: ${error.message}`); + } + }; + })(this); } _emitRuntimeError(error, phase) { diff --git a/package.json b/package.json index 4ecb601..de2bc73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@10k24/p5b", - "version": "1.1.1", + "version": "1.2.0", "description": "Run p5.js sketches in Node.js and stream RGBA pixel buffers", "author": "10k24", "main": "p5b.js", @@ -32,7 +32,7 @@ "test": "bun test", "test:watch": "bun test --watch", "lint": "bunx eslint", - "lint:fix": "bunx eslint --fix", + "fix": "bunx eslint --fix", "docs": "node scripts/build-readme.js" }, "license": "LGPL-2.1-only", diff --git a/templates/README.dot b/templates/README.dot index 658f7a3..1dfd996 100644 --- a/templates/README.dot +++ b/templates/README.dot @@ -6,7 +6,6 @@ NOTE: several features are untested or unsupported, including the following: - p5.js v2.x - webgl - shaders -- images - video - sound - third party plugins or extensions @@ -42,22 +41,30 @@ Creates a new P5b instance with the given options. #### `run()` -Start sketch execution and begin rendering frames. +Start or resume sketch execution. On first call, initializes the p5 instance. After `stop()`, resumes the draw loop. Throws if called after `remove()`. ```javascript {{=it.stubs.run}} ``` -Throws if already running. - #### `stop()` -Stop sketch execution and clean up resources. +Pause sketch execution. The p5 instance and canvas are kept alive. Call `run()` to resume. ```javascript {{=it.stubs.stop}} ``` +#### `remove()` + +Fully tear down the p5 instance and free all resources. Calling `run()` after `remove()` throws. + +```javascript +{{=it.stubs.remove}} +``` + +`clear()` is an alias for `remove()`. + #### `toFrame()` Get current canvas as a Uint8Array RGBA buffer. @@ -130,13 +137,45 @@ Example: read pixel at (x, y): - Reducing `width` / `height` - Optimizing `draw()` logic +### Happy Path Optimization + +When your sketch calls `createCanvas(w, h)` with dimensions that exactly match the p5b `width` and `height` config, p5b reads pixels directly from the canvas without any resizing step. This is ~2× faster per frame. + +```javascript +// Fast: canvas matches p5b output dimensions — no resize +const p5b = new P5b({ width: 512, height: 512, ... }); +// In sketch: createCanvas(512, 512) + +// Slower: canvas is larger than p5b output — resized every frame +const p5b = new P5b({ width: 256, height: 256, ... }); +// In sketch: createCanvas(512, 512) +``` + +### Browser Preview (p5.js Web Editor) + +p5b sets `navigator.userAgent` to `"p5b-dom/"` so sketches can detect the headless environment. Use this to scale up the canvas for a readable preview when running in the browser, while keeping the output dimensions small for p5b: + +```javascript +function setup() { + createCanvas(64, 64); + if (!navigator.userAgent.includes('p5b')) { + resizeCanvas( + floor(min(windowWidth, windowHeight) / width) * width, + floor(min(windowWidth, windowHeight) / height) * height + ); + } +} +``` + +This scales the canvas to the largest integer multiple that fits the window — no CSS, no interpolation artifacts. + ## Transport Layer For streaming frames to external systems, see [examples/ex-p5b-zmq.js](examples/ex-p5b-zmq.js) for a ZeroMQ adapter reference. ## Environment -**Node.js only.** p5b uses JSDOM for headless Canvas and native Node.js APIs. Browsers are not supported. +**Node.js only.** p5b uses a custom headless DOM shim with native Node.js APIs and `canvas`. Browsers are not supported. ## Credits diff --git a/templates/stubs/readme-remove.js b/templates/stubs/readme-remove.js new file mode 100644 index 0000000..686ca41 --- /dev/null +++ b/templates/stubs/readme-remove.js @@ -0,0 +1 @@ +p5b.remove(); // or p5b.clear() diff --git a/test-preload.js b/test-preload.js new file mode 100644 index 0000000..715c81f --- /dev/null +++ b/test-preload.js @@ -0,0 +1,10 @@ +const { spawnSync } = require("child_process"); + +const result = spawnSync("bun", ["run", "lint"], { stdio: "inherit" }); +if (result.status !== 0) { + console.error("Lint failed"); + process.exit(1); +} + +spawnSync("bun", ["run", "docs"], { stdio: "inherit" }); +console.log("Preload complete"); diff --git a/test/api-compat.test.js b/test/api-compat.test.js new file mode 100644 index 0000000..9186671 --- /dev/null +++ b/test/api-compat.test.js @@ -0,0 +1,140 @@ +const { describe, it, expect } = require("bun:test"); +const { P5b } = require("../p5b.js"); +const fs = require("fs"); +const path = require("path"); + +describe("API Compatibility: loadJSON", () => { + it("should load local JSON file", async (done) => { + const testJsonPath = path.join(process.cwd(), "test-data.json"); + fs.writeFileSync(testJsonPath, JSON.stringify({ foo: "bar", value: 123 })); + let loaded = null; + const p5b = new P5b({ + preload: async function() { + loaded = await loadJSON("test-data.json"); + }, + setup: function() {}, + draw: function() {} + }); + p5b.on("frame", () => { + expect(loaded).toBeDefined(); + expect(loaded.foo).toBe("bar"); + expect(loaded.value).toBe(123); + fs.unlinkSync(testJsonPath); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("should throw on missing file", async (done) => { + let error = null; + const p5b = new P5b({ + preload: async function() { + try { + await loadJSON("does-not-exist.json"); + } catch (e) { + error = e; + } + }, + setup: function() {}, + draw: function() {} + }); + p5b.on("frame", () => { + expect(error).toBeDefined(); + // Accept ENOENT or custom error messages + expect( + /Failed to load JSON|Error loading JSON|ENOENT/.test(error.message) + ).toBe(true); + p5b.stop(); + done(); + }); + p5b.run(); + }); +}); + +describe("API Compatibility: Noop Functions", () => { + it("should not throw for saveCanvas, saveJSON, print", (done) => { + const p5b = new P5b({ + setup: function() { + expect(() => saveCanvas()).not.toThrow(); + expect(() => saveJSON({ a: 1 }, "file.json")).not.toThrow(); + expect(() => print("hello")).not.toThrow(); + }, + draw: function() {} + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should not throw for event handler noops", (done) => { + const p5b = new P5b({ + setup: function() { + expect(() => mousePressed()).not.toThrow(); + expect(() => keyPressed()).not.toThrow(); + expect(() => touchStarted()).not.toThrow(); + }, + draw: function() {} + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); +}); + +describe("API Compatibility: Mouse/Keyboard Properties", () => { + it("should provide default values for mouse/keyboard globals", (done) => { + const p5b = new P5b({ + setup: function() { + expect(mouseX).toBe(0); + expect(mouseY).toBe(0); + expect(key).toBe(""); + expect(keyCode).toBe(0); + expect(accelerationX).toBe(0); + expect(accelerationY).toBe(0); + expect(accelerationZ).toBe(0); + }, + draw: function() {} + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); +}); + +describe("API Compatibility: loadImage", () => { + it("should load a local image file", (done) => { + const testImagePath = path.join("test/fixtures/img", "natalie-kinnear-CC2Bfvk2-tU-unsplash.jpg"); + let loadedImg = null; + const p5b = new P5b({ + preload: function() { + loadedImg = loadImage(testImagePath); + }, + setup: function() {}, + draw: function() {} + }); + p5b.on("frame", () => { + expect(loadedImg).toBeDefined(); + expect(loadedImg.width).toBeGreaterThan(0); + expect(loadedImg.height).toBeGreaterThan(0); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("should call failureCallback on missing image file", (done) => { + let error = null; + const p5b = new P5b({ + preload: function() { + loadImage("does-not-exist.jpg", null, function(err) { error = err; }); + }, + setup: function() {}, + draw: function() {} + }); + p5b.on("frame", () => { + expect(error).toBeDefined(); + expect(/Failed to load image|ENOENT/.test(error.message)).toBe(true); + p5b.stop(); + done(); + }); + p5b.run(); + }); +}); diff --git a/test/fixtures/data/test.csv b/test/fixtures/data/test.csv new file mode 100644 index 0000000..4a286e3 --- /dev/null +++ b/test/fixtures/data/test.csv @@ -0,0 +1,4 @@ +name,age,city +Alice,30,New York +Bob,25,Los Angeles +Carol,35,Chicago diff --git a/test/fixtures/data/test.txt b/test/fixtures/data/test.txt new file mode 100644 index 0000000..0c2aa38 --- /dev/null +++ b/test/fixtures/data/test.txt @@ -0,0 +1,3 @@ +line one +line two +line three diff --git a/test/fixtures/font/OFL.txt b/test/fixtures/font/OFL.txt new file mode 100644 index 0000000..11d9f5c --- /dev/null +++ b/test/fixtures/font/OFL.txt @@ -0,0 +1,5 @@ +Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source Code Pro'. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is available with a FAQ at: http://scripts.sil.org/OFL \ No newline at end of file diff --git a/test/fixtures/font/SourceCodePro-Regular.ttf b/test/fixtures/font/SourceCodePro-Regular.ttf new file mode 100644 index 0000000..10c73d7 Binary files /dev/null and b/test/fixtures/font/SourceCodePro-Regular.ttf differ diff --git a/test/fixtures/img/natalie-kinnear-CC2Bfvk2-tU-unsplash.jpg b/test/fixtures/img/natalie-kinnear-CC2Bfvk2-tU-unsplash.jpg new file mode 100644 index 0000000..6889b2e Binary files /dev/null and b/test/fixtures/img/natalie-kinnear-CC2Bfvk2-tU-unsplash.jpg differ diff --git a/test/fixtures/sketches/global-scope.js b/test/fixtures/sketches/global-scope.js new file mode 100644 index 0000000..85e2a30 --- /dev/null +++ b/test/fixtures/sketches/global-scope.js @@ -0,0 +1,11 @@ +let hello = "I am a global variable"; +let count = 42; + +function setup() { +} + +function draw() { + global.found_hello = hello; + global.found_count = count; + noLoop(); +} diff --git a/test/fixtures/sketches/globals.js b/test/fixtures/sketches/globals.js new file mode 100644 index 0000000..d90a52c --- /dev/null +++ b/test/fixtures/sketches/globals.js @@ -0,0 +1,69 @@ +function setup() { + const results = global.results = {}; + results.pi = PI; + results.two_pi = TWO_PI; + results.half_pi = HALF_PI; + results.quarter_pi = QUARTER_PI; + results.tau = TAU; + + results.degrees = DEGREES; + results.radians = RADIANS; + + results.abs = abs(-5); + results.ceil = ceil(4.2); + results.floor = floor(4.8); + results.round = round(4.5); + results.pow = pow(2, 3); + results.sqrt = sqrt(16); + results.exp = exp(1); + results.log = log(Math.E); + results.max = max(1, 5, 3); + results.min = min(1, 5, 3); + + results.sq = sq(4); + results.sq_neg = sq(-3); + results.mag = mag(3, 4); + results.mag_neg = mag(-3, -4); + results.fract = fract(1.5); + results.fract_int = fract(5); + results.fract_neg = fract(-1.5); + + results.map = map(50, 0, 100, 0, 1000); + results.lerp = lerp(0, 100, 0.5); + results.constrain = constrain(150, 0, 100); + results.constrain_in_range = constrain(50, 0, 100); + results.dist = dist(0, 0, 3, 4); + results.dist_3d = dist(0, 0, 0, 2, 3, 4); + + results.random = random(); + results.random_range = random(10); + results.random_min_max = random(5, 10); + results.random_gaussian = randomGaussian(); + results.noise = noise(0.5); + results.noise_2d = noise(0.5, 0.5); + + results.cos = cos(0); + results.sin = sin(0); + results.tan = tan(0); + results.acos = acos(1); + results.asin = asin(0); + results.atan = atan(0); + + results.norm = norm(20, 0, 50); + + results.abs_neg = abs(-1); + results.ceil_neg = ceil(-1.9); + results.floor_neg = floor(-1.9); + results.dist_identical = dist(2, 3, 2, 3); + results.dist_identical_3d = dist(2, 3, 5, 2, 3, 5); + + results.lerp_start = lerp(0, 5, 0); + results.lerp_stop = lerp(0, 5, 1); + results.lerp_avg = lerp(0, 5, 0.5); + + noLoop(); +} + +function draw() { + background(0); +} diff --git a/test/fixtures/sketches/loadimage.js b/test/fixtures/sketches/loadimage.js new file mode 100644 index 0000000..c5063e5 --- /dev/null +++ b/test/fixtures/sketches/loadimage.js @@ -0,0 +1,16 @@ +let img; + +function preload() { + img = loadImage("../img/natalie-kinnear-CC2Bfvk2-tU-unsplash.jpg"); +} + +function setup() { + const theMax = max(img.width, img.height); + createCanvas(theMax, theMax); +} + +function draw() { + background("#0000FF"); + image(img, 0, 0, img.width, img.height); + noLoop(); +} diff --git a/test/fixtures/sketches/scaling.js b/test/fixtures/sketches/scaling.js new file mode 100644 index 0000000..7cbf9e5 --- /dev/null +++ b/test/fixtures/sketches/scaling.js @@ -0,0 +1,10 @@ +function setup() { + createCanvas(4, 4); +} + +function draw() { + background(255); + fill(255, 0, 0); + noStroke(); + rect(0, 0, 2, 2); +} diff --git a/test/fixtures/sketches/shapes.js b/test/fixtures/sketches/shapes.js index 066dbed..f655ba6 100644 --- a/test/fixtures/sketches/shapes.js +++ b/test/fixtures/sketches/shapes.js @@ -31,4 +31,5 @@ function setup() { } function draw() { + noLoop(); } diff --git a/test/integration/globals.test.js b/test/integration/globals.test.js new file mode 100644 index 0000000..562ec07 --- /dev/null +++ b/test/integration/globals.test.js @@ -0,0 +1,884 @@ +const { describe, it, expect } = require("bun:test"); +const { P5b } = require("../../p5b.js"); + +describe("P5b Globals - p5.js v1.x Compatibility", () => { + describe("Trigonometry Constants", () => { + it("should have PI", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.PI).toBe(Math.PI); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have TWO_PI", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.TWO_PI).toBe(Math.PI * 2); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have HALF_PI", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.HALF_PI).toBe(Math.PI / 2); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have QUARTER_PI", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.QUARTER_PI).toBe(Math.PI / 4); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have TAU", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.TAU).toBe(Math.PI * 2); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have DEGREES", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.DEGREES).toBe("degrees"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have RADIANS", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.RADIANS).toBe("radians"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + }); + + describe("Graphics Renderer Constants", () => { + it("should have P2D", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.P2D).toBe("p2d"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have WEBGL", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.WEBGL).toBe("webgl"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have WEBGL2", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.WEBGL2).toBe("webgl2"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + }); + + describe("Shape/Mode Constants", () => { + it("should have CORNER, CORNERS, RADIUS, CENTER", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.CORNER).toBe("corner"); + expect(global.CORNERS).toBe("corners"); + expect(global.RADIUS).toBe("radius"); + expect(global.CENTER).toBe("center"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have LEFT, RIGHT, TOP, BOTTOM, BASELINE", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.LEFT).toBe("left"); + expect(global.RIGHT).toBe("right"); + expect(global.TOP).toBe("top"); + expect(global.BOTTOM).toBe("bottom"); + expect(global.BASELINE).toBe("alphabetic"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have CLOSE, OPEN, CHORD, PIE", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.CLOSE).toBe("close"); + expect(global.OPEN).toBe("open"); + expect(global.CHORD).toBe("chord"); + expect(global.PIE).toBe("pie"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have ROUND, SQUARE, PROJECT, BEVEL, MITER", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.ROUND).toBe("round"); + expect(global.SQUARE).toBe("butt"); + expect(global.PROJECT).toBe("square"); + expect(global.BEVEL).toBe("bevel"); + expect(global.MITER).toBe("miter"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have POINTS, LINES, LINE_STRIP, LINE_LOOP", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.POINTS).toBe(0x0000); + expect(global.LINES).toBe(0x0001); + expect(global.LINE_STRIP).toBe(0x0003); + expect(global.LINE_LOOP).toBe(0x0002); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have TRIANGLES, TRIANGLE_FAN, TRIANGLE_STRIP", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.TRIANGLES).toBe(0x0004); + expect(global.TRIANGLE_FAN).toBe(0x0006); + expect(global.TRIANGLE_STRIP).toBe(0x0005); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have LINEAR, QUADRATIC, BEZIER, CURVE", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.LINEAR).toBe("linear"); + expect(global.QUADRATIC).toBe("quadratic"); + expect(global.BEZIER).toBe("bezier"); + expect(global.CURVE).toBe("curve"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + }); + + describe("Color Constants", () => { + it("should have RGB, HSB, HSL", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.RGB).toBe("rgb"); + expect(global.HSB).toBe("hsb"); + expect(global.HSL).toBe("hsl"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + }); + + describe("Blend Mode Constants", () => { + it("should have basic blend modes", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.BLEND).toBe("source-over"); + expect(global.ADD).toBe("lighter"); + expect(global.REMOVE).toBe("destination-out"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have blend modes", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.DARKEST).toBe("darken"); + expect(global.LIGHTEST).toBe("lighten"); + expect(global.DIFFERENCE).toBe("difference"); + expect(global.SUBTRACT).toBe("subtract"); + expect(global.EXCLUSION).toBe("exclusion"); + expect(global.MULTIPLY).toBe("multiply"); + expect(global.SCREEN).toBe("screen"); + expect(global.REPLACE).toBe("copy"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have advanced blend modes", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.OVERLAY).toBe("overlay"); + expect(global.HARD_LIGHT).toBe("hard-light"); + expect(global.SOFT_LIGHT).toBe("soft-light"); + expect(global.DODGE).toBe("color-dodge"); + expect(global.BURN).toBe("color-burn"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + }); + + describe("Cursor/Input Constants", () => { + it("should have cursor constants", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.ARROW).toBe("default"); + expect(global.CROSS).toBe("crosshair"); + expect(global.HAND).toBe("pointer"); + expect(global.MOVE).toBe("move"); + expect(global.TEXT).toBe("text"); + expect(global.WAIT).toBe("wait"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have key code constants", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.ALT).toBe(18); + expect(global.CONTROL).toBe(17); + expect(global.SHIFT).toBe(16); + expect(global.OPTION).toBe(18); + expect(global.BACKSPACE).toBe(8); + expect(global.DELETE).toBe(46); + expect(global.TAB).toBe(9); + expect(global.ENTER).toBe(13); + expect(global.RETURN).toBe(13); + expect(global.ESCAPE).toBe(27); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have arrow key constants", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.UP_ARROW).toBe(38); + expect(global.DOWN_ARROW).toBe(40); + expect(global.LEFT_ARROW).toBe(37); + expect(global.RIGHT_ARROW).toBe(39); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + }); + + describe("Typography Constants", () => { + it("should have typography constants", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.NORMAL).toBe("normal"); + expect(global.ITALIC).toBe("italic"); + expect(global.BOLD).toBe("bold"); + expect(global.BOLDITALIC).toBe("bold italic"); + expect(global.CHAR).toBe("CHAR"); + expect(global.WORD).toBe("WORD"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + }); + + describe("Other Constants", () => { + it("should have AUTO", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.AUTO).toBe("auto"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have STROKE, FILL, TEXTURE, IMMEDIATE", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.STROKE).toBe("stroke"); + expect(global.FILL).toBe("fill"); + expect(global.TEXTURE).toBe("texture"); + expect(global.IMMEDIATE).toBe("immediate"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have NEAREST, REPEAT, CLAMP, MIRROR", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.NEAREST).toBe("nearest"); + expect(global.REPEAT).toBe("repeat"); + expect(global.CLAMP).toBe("clamp"); + expect(global.MIRROR).toBe("mirror"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have FLAT, SMOOTH", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.FLAT).toBe("flat"); + expect(global.SMOOTH).toBe("smooth"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have LANDSCAPE, PORTRAIT", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.LANDSCAPE).toBe("landscape"); + expect(global.PORTRAIT).toBe("portrait"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + }); + + describe("Math Functions (Pass-through)", () => { + it("should have abs as Math.abs", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.abs).toBe(Math.abs); + expect(global.abs(-5)).toBe(5); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have ceil as Math.ceil", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.ceil).toBe(Math.ceil); + expect(global.ceil(4.2)).toBe(5); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have floor as Math.floor", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.floor).toBe(Math.floor); + expect(global.floor(4.8)).toBe(4); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have round as Math.round", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.round).toBe(Math.round); + expect(global.round(4.5)).toBe(5); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have pow as Math.pow", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.pow).toBe(Math.pow); + expect(global.pow(2, 3)).toBe(8); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have sqrt as Math.sqrt", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.sqrt).toBe(Math.sqrt); + expect(global.sqrt(16)).toBe(4); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have exp as Math.exp", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.exp).toBe(Math.exp); + expect(global.exp(1)).toBeCloseTo(Math.E, 5); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have log as Math.log", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.log).toBe(Math.log); + expect(global.log(Math.E)).toBeCloseTo(1, 5); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have max as Math.max", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.max).toBe(Math.max); + expect(global.max(1, 5, 3)).toBe(5); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have min as Math.min", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.min).toBe(Math.min); + expect(global.min(1, 5, 3)).toBe(1); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have sq", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.sq).toBeDefined(); + expect(global.sq(4)).toBe(16); + expect(global.sq(-3)).toBe(9); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have mag", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.mag).toBeDefined(); + expect(global.mag(3, 4)).toBe(5); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have fract", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.fract).toBeDefined(); + expect(global.fract(1.5)).toBe(0.5); + expect(global.fract(-1.5)).toBe(0.5); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + }); + + describe("Random/Noise Functions", () => { + it("should have random", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.random).toBeDefined(); + const r = global.random(); + expect(typeof r).toBe("number"); + expect(r).toBeGreaterThanOrEqual(0); + expect(r).toBeLessThan(1); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have randomSeed", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.randomSeed).toBeDefined(); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have randomGaussian", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.randomGaussian).toBeDefined(); + const r = global.randomGaussian(); + expect(typeof r).toBe("number"); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have noise", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.noise).toBeDefined(); + const n = global.noise(0); + expect(typeof n).toBe("number"); + expect(n).toBeGreaterThanOrEqual(0); + expect(n).toBeLessThanOrEqual(1); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have noiseSeed", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.noiseSeed).toBeDefined(); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have noiseDetail", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.noiseDetail).toBeDefined(); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + }); + + describe("Utility Functions", () => { + it("should have map", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.map).toBeDefined(); + expect(global.map(50, 0, 100, 0, 1000)).toBe(500); + expect(global.map(0, 0, 100, -10, 10)).toBe(-10); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have lerp", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.lerp).toBeDefined(); + expect(global.lerp(0, 100, 0.5)).toBe(50); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have constrain", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.constrain).toBeDefined(); + expect(global.constrain(150, 0, 100)).toBe(100); + expect(global.constrain(50, 0, 100)).toBe(50); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have dist", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.dist).toBeDefined(); + expect(global.dist(0, 0, 3, 4)).toBe(5); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have lerpColor", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.lerpColor).toBeDefined(); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + }); + + describe("String Formatting Functions", () => { + it("should have nf", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.nf).toBeDefined(); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have nfc", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.nfc).toBeDefined(); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have nfp", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.nfp).toBeDefined(); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have nfs", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.nfs).toBeDefined(); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have join", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.join).toBeDefined(); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have split", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.split).toBeDefined(); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have splitTokens", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.splitTokens).toBeDefined(); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("should have trim", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.trim).toBeDefined(); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + }); + + describe("Other Functions", () => { + it("should have createVector", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + setup: () => { + expect(global.createVector).toBeDefined(); + }, + draw: () => { background(0); noLoop(); } + }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + }); +}); diff --git a/test/integration/integration.test.js b/test/integration/integration.test.js index 8eae141..f816789 100644 --- a/test/integration/integration.test.js +++ b/test/integration/integration.test.js @@ -3,6 +3,9 @@ const { describe, it, expect } = require("bun:test"); const path = require("path"); const { P5b } = require("../../p5b"); +// TODO: build out more utils like this for brevity +const doneErr = (err) => { p5b.stop(); done(err.error); }; + describe("P5b Integration - Buffer Analysis", () => { function testBackgroundColorScenarios(done) { const scenarios = [ @@ -98,6 +101,118 @@ describe("P5b Integration - Buffer Analysis", () => { testBackgroundColorScenarios(done); }); + it("should handle canvas larger than output with filler pixels", (done) => { + const p5b = new P5b({ + width: 4, + height: 8, + fps: 30, + setup: () => { + createCanvas(8, 8); + background(255, 0, 0); + }, + draw: () => { + noLoop(); + } + }); + + p5b.on("frame", (buffer) => { + expect(buffer.length).toBe(4 * 8 * 4); + + const px = (x, y) => { + const i = (y * 4 + x) * 4; + return [buffer[i], buffer[i+1], buffer[i+2], buffer[i+3]]; + }; + + expect(px(0, 0)).toEqual([255, 0, 0, 255]); + expect(px(3, 0)).toEqual([255, 0, 0, 255]); + expect(px(0, 3)).toEqual([255, 0, 0, 255]); + expect(px(3, 3)).toEqual([255, 0, 0, 255]); + + // Blank buffer expected below the scaled frame + expect(px(0, 4)).toEqual([0, 0, 0, 0]); + expect(px(0, 7)).toEqual([0, 0, 0, 0]); + expect(px(3, 4)).toEqual([0, 0, 0, 0]); + expect(px(3, 7)).toEqual([0, 0, 0, 0]); + + p5b.stop(); + done(); + }); + + p5b.run(); + }); + + it("should handle canvas wider than output with filler pixels", (done) => { + const p5b = new P5b({ + width: 8, + height: 4, + fps: 30, + setup: () => { + createCanvas(8, 8); + background(255, 0, 0); + }, + draw: () => { + noLoop(); + } + }); + + p5b.on("frame", (buffer) => { + expect(buffer.length).toBe(8 * 4 * 4); + + const px = (x, y) => { + const i = (y * 8 + x) * 4; + return [buffer[i], buffer[i+1], buffer[i+2], buffer[i+3]]; + }; + + expect(px(0, 0)).toEqual([255, 0, 0, 255]); + expect(px(3, 0)).toEqual([255, 0, 0, 255]); + expect(px(0, 3)).toEqual([255, 0, 0, 255]); + expect(px(3, 3)).toEqual([255, 0, 0, 255]); + + // Blank buffer expected after the scaled frame + expect(px(4, 0)).toEqual([0, 0, 0, 0]); + expect(px(7, 0)).toEqual([0, 0, 0, 0]); + expect(px(4, 3)).toEqual([0, 0, 0, 0]); + expect(px(7, 3)).toEqual([0, 0, 0, 0]); + + p5b.stop(); + done(); + }); + + p5b.run(); + }); + + it("should scale canvas to smaller output without filler", (done) => { + const p5b = new P5b({ + width: 4, + height: 4, + fps: 30, + setup: () => { + createCanvas(8, 8); + background(255, 0, 0); + }, + draw: () => { + noLoop(); + } + }); + + p5b.on("frame", (buffer) => { + expect(buffer.length).toBe(4 * 4 * 4); + + const px = (x, y) => { + const i = (y * 4 + x) * 4; + return [buffer[i], buffer[i+1], buffer[i+2], buffer[i+3]]; + }; + + expect(px(0, 0)).toEqual([255, 0, 0, 255]); + expect(px(3, 3)).toEqual([255, 0, 0, 255]); + + p5b.stop(); + done(); + }); + + p5b.run(); + }); + it("should emit buffers with correct dimensions", (done) => { const WIDTH = 64; const HEIGHT = 64; @@ -192,13 +307,14 @@ describe("P5b Integration - Buffer Analysis", () => { createCanvas(400, 400); }, draw: () => { - background(100, 150, 200); + background(100, 150, 200, 120); + noLoop(); } }); p5b.on("frame", (buffer) => { - for (let i = 3; i < Math.min(buffer.length, 64); i += 4) { - expect(buffer[i]).toBe(255); + for (let i = 0; i < Math.min(buffer.length, 64); i += 4) { + expect(Math.abs(buffer[i+3] - 120)).toBeLessThanOrEqual(8); } p5b.stop(); done(); @@ -223,16 +339,21 @@ describe("P5b Integration - Buffer Analysis", () => { gfx.background(100, 200, 50); gfx.loadPixels(); image(gfx, 0, 0); + noLoop(); } }); p5b.on("frame", (buffer) => { const r = buffer[0]; + const g = buffer[1]; + const b = buffer[2]; const a = buffer[3]; expect(r).toBe(100); + expect(g).toBe(200); + expect(b).toBe(50); expect(a).toBe(255); - let offset = WIDTH/2 * 4; + const offset = WIDTH/2 * 4; const r2 = buffer[offset]; const g2 = buffer[offset+1]; const b2 = buffer[offset+2]; @@ -250,7 +371,7 @@ describe("P5b Integration - Buffer Analysis", () => { }); it("should load fixture sketch and assert background color", (done) => { - const sketchPath = path.resolve(__dirname, "../fixtures/sketches/shapes.js"); + const sketchPath = path.resolve(process.cwd(), "test/fixtures/sketches/shapes.js"); const WIDTH = 32; const HEIGHT = 32; const p5b = new P5b({ @@ -265,9 +386,9 @@ describe("P5b Integration - Buffer Analysis", () => { const g = buffer[1]; const b = buffer[2]; const a = buffer[3]; - expect(r).toBe(70); - expect(g).toBe(130); - expect(b).toBe(180); + expect(Math.abs(r - 70)).toBeLessThanOrEqual(8); + expect(Math.abs(g - 130)).toBeLessThanOrEqual(8); + expect(Math.abs(b - 180)).toBeLessThanOrEqual(8); expect(a).toBe(255); p5b.stop(); done(); @@ -527,7 +648,7 @@ describe("P5b Integration - Buffer Analysis", () => { }); }); -describe("P5b Graphics Pooling - Integration", () => { +describe("P5b Integration - Graphics Pooling", () => { it("should create and remove graphics without throwing", (done) => { const p5b = new P5b({ width: 32, height: 32, @@ -642,3 +763,1976 @@ describe("P5b Graphics Pooling - Integration", () => { p5b.run(); }); }); + +describe("P5b Integration - loadImage", () => { + it("should load image in preload and render in draw", (done) => { + const testImagePath = path.join(process.cwd(), "test/fixtures/img/natalie-kinnear-CC2Bfvk2-tU-unsplash.jpg"); + let loadedImg = null; + + const p5b = new P5b({ + width: 32, height: 32, + fps: 30, + preload: () => { + loadedImg = loadImage(testImagePath); + }, + setup: () => { + createCanvas(loadedImg.width, loadedImg.height); + }, + draw: () => { + background(0); + image(loadedImg, 0, 0, loadedImg.width, loadedImg.height); + noLoop(); + } + }); + + p5b.on("error", doneErr); + + p5b.on("frame", (buffer) => { + expect(buffer).toBeInstanceOf(Uint8Array); + expect(buffer.length).toBe(32 * 32 * 4); + + const hasColor = buffer.slice(0, 256).some((v, i) => i % 4 !== 3 && v > 0); + expect(hasColor).toBe(true); + + p5b.stop(); + done(); + }); + + p5b.run(); + }); + + it("should handle loadImage error gracefully", (done) => { + let error = null; + + const p5b = new P5b({ + width: 32, height: 32, + fps: 30, + preload: () => { + loadImage("does-not-exist.jpg", () => {}, (err) => { + error = err; + }); + }, + setup: () => { + createCanvas(100, 100); + }, + draw: () => { + background(0); + noLoop(); + } + }); + + p5b.on("frame", (_buffer) => { + p5b.stop(); + done(); + }); + + p5b.run(); + }); + + it("should scale image when drawing with different dimensions", (done) => { + const testImagePath = path.join(process.cwd(), "test/fixtures/img/natalie-kinnear-CC2Bfvk2-tU-unsplash.jpg"); + let loadedImg = null; + + const p5b = new P5b({ + width: 16, height: 16, + fps: 30, + preload: () => { + loadedImg = loadImage(testImagePath); + }, + setup: () => { + createCanvas(loadedImg.width, loadedImg.height); + }, + draw: () => { + background(0); + image(loadedImg, 0, 0, 16, 16); + noLoop(); + } + }); + + p5b.on("error", doneErr); + + p5b.on("frame", (buffer) => { + expect(buffer).toBeInstanceOf(Uint8Array); + expect(buffer.length).toBe(16 * 16 * 4); + + p5b.stop(); + done(); + }); + + p5b.run(); + }); + + it("should handle aspect ratio mismatch (wider output)", (done) => { + const p5b = new P5b({ + width: 60, + height: 20, + fps: 30, + setup: () => { + createCanvas(800, 400); + background(255, 0, 0); + }, + draw: () => { + noLoop(); + } + }); + + p5b.on("frame", (buffer) => { + const width = 60; + const height = 20; + const px = (x, y) => { + const i = (y * width + x) * 4; + return [buffer[i], buffer[i+1], buffer[i+2], buffer[i+3]]; + }; + + expect(px(0, 0)).toEqual([255, 0, 0, 255]); + expect(px(39, 0)).toEqual([255, 0, 0, 255]); + expect(px(0, 19)).toEqual([255, 0, 0, 255]); + expect(px(39, 19)).toEqual([255, 0, 0, 255]); + + expect(px(40, 0)).toEqual([0, 0, 0, 0]); + expect(px(59, 0)).toEqual([0, 0, 0, 0]); + expect(px(40, 19)).toEqual([0, 0, 0, 0]); + expect(px(59, 19)).toEqual([0, 0, 0, 0]); + + p5b.stop(); + done(); + }); + + p5b.run(); + }); + + it("should handle aspect ratio mismatch (taller output)", (done) => { + const p5b = new P5b({ + width: 120, + height: 50, + fps: 30, + setup: () => { + createCanvas(400, 500); + background(0, 255, 0); + }, + draw: () => { + noLoop(); + } + }); + + p5b.on("frame", (buffer) => { + const width = 120; + const height = 50; + const px = (x, y) => { + const i = (y * width + x) * 4; + return [buffer[i], buffer[i+1], buffer[i+2], buffer[i+3]]; + }; + + expect(px(0, 0)).toEqual([0, 255, 0, 255]); + expect(px(39, 0)).toEqual([0, 255, 0, 255]); + expect(px(0, 49)).toEqual([0, 255, 0, 255]); + expect(px(39, 49)).toEqual([0, 255, 0, 255]); + + expect(px(40, 0)).toEqual([0, 0, 0, 0]); + expect(px(119, 0)).toEqual([0, 0, 0, 0]); + expect(px(40, 49)).toEqual([0, 0, 0, 0]); + expect(px(119, 49)).toEqual([0, 0, 0, 0]); + + p5b.stop(); + done(); + }); + + p5b.run(); + }); +}); + +describe("P5b Integration - windowResized", () => { + it("should call user-defined windowResized handler", (done) => { + let windowResizedCalled = false; + + const p5b = new P5b({ + width: 32, height: 32, + fps: 30, + setup: () => { + createCanvas(100, 100); + }, + draw: () => { + background(100); + }, + windowResized: () => { + windowResizedCalled = true; + } + }); + + p5b.on("frame", (buffer) => { + windowResized(); + expect(windowResizedCalled).toBe(true); + p5b.stop(); + done(); + }); + + p5b.run(); + }); + + it("should update DOM dimensions after windowResized", (done) => { + const p5b = new P5b({ + width: 32, height: 32, + fps: 30, + setup: () => { + createCanvas(100, 100); + }, + draw: () => { + background(100); + } + }); + + p5b.on("frame", () => { + windowResized(); + const domAfter = p5b._dom; + expect(domAfter.width).toBe(32); + expect(domAfter.height).toBe(32); + p5b.stop(); + done(); + }); + + p5b.run(); + }); +}); + +describe("P5b Integration - WEBGL Mode", () => { + it("should emit error when WEBGL mode is requested", (done) => { + const p5b = new P5b({ + width: 32, height: 32, + fps: 30, + setup: () => { + createCanvas(100, 100, WEBGL); + }, + draw: () => { + background(100); + } + }); + + p5b.on("error", (err) => { + expect(err.phase).toBeDefined(); + expect(err.error).toBeDefined(); + p5b.stop(); + done(); + }); + + p5b.run(); + }); +}); + +describe("P5b Integration - Time Functions", () => { + it("should return current time values matching native Date", (done) => { + const now = new Date(); + + const p5b = new P5b({ + width: 16, height: 16, + fps: 30, + setup: () => { + createCanvas(100, 100); + }, + draw: () => { + const results = global.results = {}; + results.year = year(); + results.month = month(); + results.day = day(); + results.hour = hour(); + results.minute = minute(); + results.second = second(); + results.millis = millis(); + noLoop(); + } + }); + + p5b.on("error", doneErr); + + p5b.on("frame", (buffer) => { + expect(global.results.year).toBe(now.getFullYear()); + expect(global.results.month).toBe(now.getMonth() + 1); + expect(global.results.day).toBe(now.getDate()); + expect(global.results.hour).toBe(now.getHours()); + // Relaxed test for minute/second due to timing gap + expect(Math.abs(global.results.minute - now.getMinutes())).toBeLessThanOrEqual(1); + expect(Math.abs(global.results.second - now.getSeconds())).toBeLessThanOrEqual(2); + expect(global.results.millis).toBeGreaterThanOrEqual(0); + expect(global.results.millis).toBeLessThanOrEqual(999); + p5b.stop(); + done(); + }); + + p5b.run(); + }); +}); + +describe("P5b Integration - Environment Functions", () => { + it("should return current frameRate from p5 instance", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + fps: 30, + setup: () => { createCanvas(100, 100); }, + draw: () => { + const currentFps = frameRate(); + global.results.fps = currentFps; + global.results.target_fps = getTargetFrameRate(); + noLoop(); + } + }); + + p5b.on("frame", (buffer) => { + // Framerate seems to be 34 for some reason + expect(Math.floor(global.results.fps) - 30).toBeLessThan(5); + expect(global.results.target_fps).toBe(30); + p5b.stop(); + done(); + }); + + p5b.run(); + }); + + it("should track isLooping state", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + fps: 30, + setup: () => { createCanvas(100, 100); noLoop(); }, + draw: () => { + global.results.isLooping = isLooping(); + } + }); + + p5b.on("frame", (buffer) => { + expect(global.results.isLooping).toBe(false); + p5b.stop(); + done(); + }); + + p5b.run(); + }); +}); + +describe("P5b Integration - Loop Control", () => { + it("should emit frames continuously when looping", (done) => { + let frameCount = 0; + const p5b = new P5b({ + width: 16, height: 16, + fps: 30, + setup: () => { createCanvas(100, 100); }, + draw: () => { frameCount++; } + }); + + p5b.on("frame", (buffer) => { + // Should get multiple frames before timeout + if (frameCount >= 3) { + expect(frameCount).toBeGreaterThanOrEqual(3); + p5b.stop(); + done(); + } + }); + + p5b.run(); + }); + + it("should stop emitting frames after noLoop() in setup", (done) => { + let framesReceived = 0; + const p5b = new P5b({ + width: 16, height: 16, + fps: 30, + setup: () => { + createCanvas(100, 100); + noLoop(); + }, + draw: () => { /* intentionally empty */ } + }); + + p5b.on("frame", () => { framesReceived++; }); + + setTimeout(() => { + // draw() always runs at least once, then stops (not continuous) + expect(framesReceived).toBe(1); + p5b.stop(); + done(); + }, 500); + + p5b.run(); + }); + + it("should emit exactly one frame when noLoop called in draw", (done) => { + let frameCount = 0; + const p5b = new P5b({ + width: 16, height: 16, + fps: 30, + setup: () => { createCanvas(100, 100); }, + draw: () => { + frameCount++; + if (frameCount === 1) { + noLoop(); + } + } + }); + + let framesReceived = 0; + p5b.on("frame", (buffer) => { + framesReceived++; + }); + + setTimeout(() => { + expect(framesReceived).toBe(1); // Only first frame + p5b.stop(); + done(); + }, 500); + + p5b.run(); + }); + + it("should resume frame emission after loop() called", (done) => { + let frameCount = 0; + let noLoopCalled = false; + const p5b = new P5b({ + width: 16, height: 16, + fps: 30, + setup: () => { createCanvas(100, 100); }, + draw: () => { + frameCount++; + if (!noLoopCalled) { + noLoopCalled = true; + noLoop(); + } + } + }); + + let framesReceived = 0; + p5b.on("frame", () => { framesReceived++; }); + + // After noLoop stops the animation, resume it externally + setTimeout(() => { loop(); }, 100); + + setTimeout(() => { + expect(framesReceived).toBeGreaterThan(1); // Should resume after loop() + p5b.stop(); + done(); + }, 500); + + p5b.run(); + }); + + it("should initially report isLooping as true", (done) => { + let loopingOnFirstFrame; + const p5b = new P5b({ + width: 16, height: 16, + fps: 30, + setup: () => { createCanvas(100, 100); }, + draw: () => { + // First frame - isLooping should be true + if (frameCount === 1) { + loopingOnFirstFrame = isLooping(); + noLoop(); + } + } + }); + + p5b.on("frame", () => { + expect(loopingOnFirstFrame).toBe(true); + p5b.stop(); + done(); + }); + + p5b.run(); + }); + + it("should report isLooping as false after noLoop()", (done) => { + let loopingAfterNoLoop; + const p5b = new P5b({ + width: 16, height: 16, + fps: 30, + setup: () => { createCanvas(100, 100); }, + draw: () => { + if (frameCount === 1) { + noLoop(); + loopingAfterNoLoop = isLooping(); + } + } + }); + + p5b.on("frame", () => { + expect(loopingAfterNoLoop).toBe(false); + p5b.stop(); + done(); + }); + + p5b.run(); + }); + + it("should stop at correct frame count when noLoop called after N frames", (done) => { + let drawCalls = 0; + const p5b = new P5b({ + width: 16, height: 16, + fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + drawCalls++; + if (drawCalls === 3) noLoop(); + } + }); + + let framesReceived = 0; + p5b.on("frame", () => { framesReceived++; }); + + setTimeout(() => { + expect(framesReceived).toBe(3); + p5b.stop(); + done(); + }, 500); + + p5b.run(); + }); + + it("should handle multiple noLoop() calls idempotently", (done) => { + let drawCalls = 0; + const p5b = new P5b({ + width: 16, height: 16, + fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + drawCalls++; + if (drawCalls === 1) { + noLoop(); + noLoop(); + noLoop(); + } + } + }); + + let framesReceived = 0; + p5b.on("frame", () => { framesReceived++; }); + + setTimeout(() => { + expect(framesReceived).toBe(1); + p5b.stop(); + done(); + }, 500); + + p5b.run(); + }); + + it("should handle loop() while already looping without doubling frame rate", (done) => { + let drawCalls = 0; + const p5b = new P5b({ + width: 16, height: 16, + fps: 30, + setup: () => { createCanvas(100, 100); }, + draw: () => { + drawCalls++; + loop(); // redundant, already looping + if (drawCalls >= 5) noLoop(); + } + }); + + let framesReceived = 0; + p5b.on("frame", () => { framesReceived++; }); + + setTimeout(() => { + // Should get exactly 5 frames, not double due to redundant loop() calls + expect(framesReceived).toBe(5); + p5b.stop(); + done(); + }, 500); + + p5b.run(); + }); + + it("should support noLoop() -> loop() -> noLoop() toggle", (done) => { + let drawCalls = 0; + const p5b = new P5b({ + width: 16, height: 16, + fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + drawCalls++; + if (drawCalls === 2) noLoop(); + } + }); + + let framesReceived = 0; + p5b.on("frame", () => { framesReceived++; }); + + // After first stop: resume, let 2 more frames fire, then stop again + setTimeout(() => { + expect(framesReceived).toBe(2); + loop(); + }, 150); + + setTimeout(() => { + expect(framesReceived).toBeGreaterThan(2); // resumed and got more frames + noLoop(); + }, 300); + + setTimeout(() => { + const countAfterSecondStop = framesReceived; + setTimeout(() => { + // No new frames after second noLoop + expect(framesReceived).toBe(countAfterSecondStop); + p5b.stop(); + done(); + }, 150); + }, 350); + + p5b.run(); + }); + + it("should support external noLoop() called from outside draw", (done) => { + const p5b = new P5b({ + width: 16, height: 16, + fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { /* continuous */ } + }); + + let framesReceived = 0; + p5b.on("frame", () => { framesReceived++; }); + + setTimeout(() => { noLoop(); }, 100); + + setTimeout(() => { + const countAtStop = framesReceived; + expect(countAtStop).toBeGreaterThan(0); + setTimeout(() => { + // No new frames after external noLoop + expect(framesReceived).toBe(countAtStop); + p5b.stop(); + done(); + }, 200); + }, 150); + + p5b.run(); + }); + + it("should support redraw() triggering one frame while stopped", (done) => { + let drawCalls = 0; + const p5b = new P5b({ + width: 16, height: 16, + fps: 60, + setup: () => { + createCanvas(100, 100); + noLoop(); + }, + draw: () => { drawCalls++; } + }); + + let framesReceived = 0; + p5b.on("frame", () => { framesReceived++; }); + + // After initial frame from setup, call redraw() twice externally + setTimeout(() => { + expect(framesReceived).toBe(1); // only the initial frame + redraw(); + }, 100); + + setTimeout(() => { + expect(framesReceived).toBe(2); // one more from redraw() + redraw(); + }, 200); + + setTimeout(() => { + expect(framesReceived).toBe(3); // one more from second redraw() + p5b.stop(); + done(); + }, 300); + + p5b.run(); + }); + + it("should preserve frameCount correctly after loop() resume", (done) => { + let frameCountAtResume; + let frameCountAfterResume; + let drawCalls = 0; + const p5b = new P5b({ + width: 16, height: 16, + fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + drawCalls++; + if (drawCalls === 3) { + frameCountAtResume = frameCount; + noLoop(); + } + if (drawCalls === 4) { + frameCountAfterResume = frameCount; + noLoop(); + } + } + }); + + p5b.on("frame", () => {}); + + setTimeout(() => { loop(); }, 200); + + setTimeout(() => { + // frameCount should be > frameCountAtResume (not reset to 0) + expect(frameCountAfterResume).toBeGreaterThan(frameCountAtResume); + p5b.stop(); + done(); + }, 500); + + p5b.run(); + }); +}); + +describe("P5b Integration - imageMode", () => { + it("imageMode(CORNER) vs imageMode(CENTER) place the same image at different positions", (done) => { + // Draw 20x20 red image at coords (50,50) in two consecutive frames with different modes. + // CORNER: top-left at (50,50) → covers x:50-69, y:50-69. px(65,65)=red, px(45,45)=black. + // CENTER: centered at (50,50) → covers x:40-59, y:40-59. px(65,65)=black, px(45,45)=red. + // Same image, same coordinates, different modes → different pixel output proves mode takes effect. + let drawCall = 0; + let cornerPx65, cornerPx45; + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + drawCall++; + background(0); + const pg = createGraphics(20, 20); + pg.background(255, 0, 0); + if (drawCall === 1) { + imageMode(CORNER); + } else { + imageMode(CENTER); + noLoop(); + } + image(pg, 50, 50); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (buffer) => { + const px = (x, y) => { const i = (y * 100 + x) * 4; return [buffer[i], buffer[i+1], buffer[i+2]]; }; + if (drawCall === 1) { + cornerPx65 = px(65, 65); + cornerPx45 = px(45, 45); + } else { + const centerPx65 = px(65, 65); + const centerPx45 = px(45, 45); + // CORNER: (65,65) inside image → red; CENTER: (65,65) outside image → black + expect(cornerPx65).toEqual([255, 0, 0]); + expect(centerPx65).toEqual([0, 0, 0]); + // CORNER: (45,45) outside image → black; CENTER: (45,45) inside image → red + expect(cornerPx45).toEqual([0, 0, 0]); + expect(centerPx45).toEqual([255, 0, 0]); + p5b.stop(); + done(); + } + }); + p5b.run(); + }); + + it("imageMode(CORNERS) stretches image between two corner coordinates", (done) => { + // CORNER (default): image(pg, 30, 30) with 20x20 image → covers x:30-49, y:30-49 + // CORNERS: image(pg, 30, 30, 70, 70) → image stretched to fill x:30-70, y:30-70 + // At px(60,60): outside CORNER image, inside CORNERS image → proves mode takes effect + let drawCall = 0; + let cornerPx60; + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + drawCall++; + background(0); + const pg = createGraphics(20, 20); + pg.background(255, 0, 0); + if (drawCall === 1) { + imageMode(CORNER); + image(pg, 30, 30); + } else { + imageMode(CORNERS); + image(pg, 30, 30, 70, 70); + noLoop(); + } + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (buffer) => { + const px = (x, y) => { const i = (y * 100 + x) * 4; return [buffer[i], buffer[i+1], buffer[i+2]]; }; + if (drawCall === 1) { + cornerPx60 = px(60, 60); + } else { + const cornersPx60 = px(60, 60); + expect(cornerPx60).toEqual([0, 0, 0]); // outside CORNER image + expect(cornersPx60).toEqual([255, 0, 0]); // inside CORNERS image + p5b.stop(); + done(); + } + }); + p5b.run(); + }); +}); + +describe("P5b Integration - Mode/Style Functions", () => { + // Helper: render one frame on a 100x100 canvas, return pixel reader + function renderFrame(sketchConfig, cb) { + const p5b = new P5b({ width: 100, height: 100, fps: 60, ...sketchConfig }); + p5b.on("error", (e) => { p5b.stop(); throw e.error; }); + p5b.on("frame", (buffer) => { + const px = (x, y) => { + const i = (y * 100 + x) * 4; + return [buffer[i], buffer[i+1], buffer[i+2], buffer[i+3]]; + }; + p5b.stop(); + cb(px); + }); + p5b.run(); + } + + it("rectMode(CORNER) places rect with top-left origin", (done) => { + renderFrame({ + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + rectMode(CORNER); + fill(255, 0, 0); noStroke(); + rect(50, 50, 20, 20); + noLoop(); + } + }, (px) => { + // interior of rect should be red + expect(px(55, 55)).toEqual([255, 0, 0, 255]); + // just outside top-left corner should be black + expect(px(45, 45)).toEqual([0, 0, 0, 255]); + done(); + }); + }); + + it("rectMode(CENTER) places rect centered on x,y", (done) => { + renderFrame({ + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + rectMode(CENTER); + fill(255, 0, 0); noStroke(); + rect(50, 50, 20, 20); + noLoop(); + } + }, (px) => { + // center should be red + expect(px(50, 50)).toEqual([255, 0, 0, 255]); + // corners at 40-59 should be red + expect(px(41, 41)).toEqual([255, 0, 0, 255]); + // outside (before top-left) should be black + expect(px(38, 38)).toEqual([0, 0, 0, 255]); + done(); + }); + }); + + it("rectMode(CORNERS) interprets args as two corners", (done) => { + renderFrame({ + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + rectMode(CORNERS); + fill(255, 0, 0); noStroke(); + rect(40, 40, 60, 60); + noLoop(); + } + }, (px) => { + // inside rect + expect(px(45, 45)).toEqual([255, 0, 0, 255]); + expect(px(55, 55)).toEqual([255, 0, 0, 255]); + // outside rect + expect(px(35, 35)).toEqual([0, 0, 0, 255]); + done(); + }); + }); + + it("ellipseMode(CENTER) places ellipse centered on x,y", (done) => { + renderFrame({ + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + ellipseMode(CENTER); + fill(255, 0, 0); noStroke(); + ellipse(50, 50, 20, 20); + noLoop(); + } + }, (px) => { + // center red + expect(px(50, 50)).toEqual([255, 0, 0, 255]); + // far outside the 10px radius should be black + expect(px(62, 50)).toEqual([0, 0, 0, 255]); + expect(px(37, 50)).toEqual([0, 0, 0, 255]); + done(); + }); + }); + + it("ellipseMode(CORNER) places ellipse with top-left at x,y", (done) => { + // In CORNER mode, ellipse(50,40,20,20): top-left=(50,40), center=(60,50) + // In CENTER mode, ellipse(50,40,20,20): center=(50,40) + // Use a single instance, two frames, to avoid concurrent-globals race + let drawCall = 0; + let cornerPx; + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + drawCall++; + background(0); + if (drawCall === 1) { + ellipseMode(CORNER); + fill(255, 0, 0); noStroke(); + ellipse(50, 40, 20, 20); + } else { + ellipseMode(CENTER); + fill(255, 0, 0); noStroke(); + ellipse(50, 40, 20, 20); + noLoop(); + } + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (buffer) => { + const px = (x, y) => { + const i = (y * 100 + x) * 4; + return [buffer[i], buffer[i+1], buffer[i+2], buffer[i+3]]; + }; + if (drawCall === 1) { + // CORNER mode: ellipse(50,40,20,20) → center=(60,50) + // px(65,50) is inside CORNER ellipse, outside CENTER ellipse + cornerPx = px(65, 50); + } else { + // CENTER mode: ellipse(50,40,20,20) → center=(50,40) + // px(65,50) is outside CENTER ellipse (distance ~18 > radius 10) → black + const centerPx = px(65, 50); + expect(cornerPx[0]).toBe(255); // red inside CORNER ellipse + expect(centerPx[0]).toBe(0); // black outside CENTER ellipse + p5b.stop(); + done(); + } + }); + p5b.run(); + }); + + it("strokeCap(SQUARE) vs strokeCap(PROJECT) extend line end differently", (done) => { + // SQUARE (butt): caps flush with endpoints; PROJECT extends strokeWeight/2 past endpoints + // Run sequentially to avoid global namespace collision between instances + renderFrame({ + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + strokeCap(SQUARE); + stroke(255, 0, 0); strokeWeight(10); noFill(); + line(30, 50, 70, 50); + noLoop(); + } + }, (px) => { + // At x=28, 2px before line start (30), SQUARE cap (flush) should be black + const squarePx = px(28, 50); + renderFrame({ + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + strokeCap(PROJECT); + stroke(255, 0, 0); strokeWeight(10); noFill(); + line(30, 50, 70, 50); + noLoop(); + } + }, (px2) => { + // At x=28, PROJECT extends 5px past start, so x=28 is 2px inside the cap — red + const projectPx = px2(28, 50); + expect(squarePx[0]).toBe(0); + expect(projectPx[0]).toBe(255); + done(); + }); + }); + }); + + it("strokeJoin(MITER) extends join point further than strokeJoin(BEVEL)", (done) => { + // V-shape: vertex(20,20), (50,70), (80,20), strokeWeight=10 + // MITER extends join to a sharp point below y=70; BEVEL cuts it off earlier + // Run sequentially to avoid global namespace collision between instances + renderFrame({ + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + strokeJoin(MITER); + stroke(255, 0, 0); strokeWeight(10); noFill(); + beginShape(); + vertex(20, 20); + vertex(50, 70); + vertex(80, 20); + endShape(); + noLoop(); + } + }, (px) => { + const miterPx = px(50, 73); + renderFrame({ + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + strokeJoin(BEVEL); + stroke(255, 0, 0); strokeWeight(10); noFill(); + beginShape(); + vertex(20, 20); + vertex(50, 70); + vertex(80, 20); + endShape(); + noLoop(); + } + }, (px2) => { + const bevelPx = px2(50, 73); + // MITER and BEVEL produce different pixel layouts at the join vertex + expect(miterPx).not.toEqual(bevelPx); + done(); + }); + }); + }); +}); + +describe("P5b Integration - Typography", () => { + it("textWidth() returns 0 for empty string", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + textSize(20); + global._tw_empty = textWidth(""); + noLoop(); + } + }); + p5b.on("frame", () => { + expect(global._tw_empty).toBe(0); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("textWidth() returns positive value for non-empty string", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + textSize(20); + global._tw_hello = textWidth("Hello"); + noLoop(); + } + }); + p5b.on("frame", () => { + expect(global._tw_hello).toBeGreaterThan(0); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("textWidth() longer string is wider than shorter string", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + textSize(20); + global._tw_short = textWidth("Hi"); + global._tw_long = textWidth("Hello World"); + noLoop(); + } + }); + p5b.on("frame", () => { + expect(global._tw_long).toBeGreaterThan(global._tw_short); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("textStyle(BOLD) produces wider glyphs than textStyle(NORMAL) with Arial", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + textFont("Arial"); + textSize(20); + textStyle(NORMAL); + global._tw_normal = textWidth("Hello World"); + textStyle(BOLD); + global._tw_bold = textWidth("Hello World"); + noLoop(); + } + }); + p5b.on("frame", () => { + expect(global._tw_bold).toBeGreaterThan(global._tw_normal); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("textStyle() getter returns the current style", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + textStyle(ITALIC); + global._ts = textStyle(); + noLoop(); + } + }); + p5b.on("frame", () => { + expect(global._ts).toBe("italic"); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("textLeading() getter/setter round-trips", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + textLeading(30); + global._tl = textLeading(); + noLoop(); + } + }); + p5b.on("frame", () => { + expect(global._tl).toBe(30); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("textLeading() affects line spacing — larger leading produces different pixel layout", (done) => { + let smallLeadingBuffer; + let largeLeadingBuffer; + let pending = 2; + + function check() { + if (--pending === 0) { + expect(smallLeadingBuffer).not.toEqual(largeLeadingBuffer); + done(); + } + } + + const p5b1 = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(255); fill(0); textSize(12); + textLeading(14); + text("line one\nline two", 5, 20); + noLoop(); + } + }); + p5b1.on("frame", (buf) => { + smallLeadingBuffer = Buffer.from(buf); + p5b1.stop(); + check(); + }); + p5b1.run(); + + const p5b2 = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(255); fill(0); textSize(12); + textLeading(40); + text("line one\nline two", 5, 20); + noLoop(); + } + }); + p5b2.on("frame", (buf) => { + largeLeadingBuffer = Buffer.from(buf); + p5b2.stop(); + check(); + }); + p5b2.run(); + }); + + it("textAlign(LEFT) vs textAlign(RIGHT) produce different pixel layouts", (done) => { + // Verify LEFT and RIGHT alignment produce visually distinct output. + // Use a single instance, two frames, to avoid concurrent-globals race. + let drawCall = 0; + let frame1Buffer; + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + drawCall++; + if (drawCall === 1) { + background(255); fill(0); textSize(20); + textAlign(LEFT); + text("Hello", 5, 60); + } else { + background(255); fill(0); textSize(20); + textAlign(RIGHT); + text("Hello", 95, 60); + noLoop(); + } + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (buffer) => { + if (drawCall === 1) { + frame1Buffer = Buffer.from(buffer); + } else { + // LEFT and RIGHT alignment place text at opposite ends → distinct pixel buffers + expect(Buffer.from(buffer).equals(frame1Buffer)).toBe(false); + p5b.stop(); + done(); + } + }); + p5b.run(); + }); + + it("textWrap() does not throw when called with WORD or CHAR", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + textWrap(WORD); + textWrap(CHAR); + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); +}); + +describe("P5b Integration - Data/IO", () => { + it("loadStrings() in preload returns array of lines", (done) => { + const txtPath = path.resolve(process.cwd(), "test/fixtures/data/test.txt"); + let lines; + + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + preload: () => { + lines = loadStrings(txtPath); + }, + setup: () => { createCanvas(16, 16); }, + draw: () => { noLoop(); } + }); + + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { + expect(Array.isArray(lines)).toBe(true); + expect(lines.length).toBe(3); + expect(lines[0]).toBe("line one"); + expect(lines[1]).toBe("line two"); + expect(lines[2]).toBe("line three"); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("loadStrings() calls callback with lines array", (done) => { + const txtPath = path.resolve(process.cwd(), "test/fixtures/data/test.txt"); + let callbackLines; + + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + preload: () => { + loadStrings(txtPath, (ls) => { callbackLines = ls; }); + }, + setup: () => { createCanvas(16, 16); }, + draw: () => { noLoop(); } + }); + + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { + expect(Array.isArray(callbackLines)).toBe(true); + expect(callbackLines.length).toBe(3); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("loadTable() in preload parses CSV with header row", (done) => { + const csvPath = path.resolve(process.cwd(), "test/fixtures/data/test.csv"); + let table; + + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + preload: () => { + table = loadTable(csvPath, "csv", "header"); + }, + setup: () => { createCanvas(16, 16); }, + draw: () => { noLoop(); } + }); + + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { + expect(table).toBeDefined(); + expect(table.getRowCount()).toBe(3); + expect(table.getString(0, "name")).toBe("Alice"); + expect(table.getString(1, "name")).toBe("Bob"); + expect(table.getString(2, "name")).toBe("Carol"); + expect(table.getString(0, "city")).toBe("New York"); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("loadTable() without header option treats first row as data", (done) => { + const csvPath = path.resolve(process.cwd(), "test/fixtures/data/test.csv"); + let table; + + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + preload: () => { + table = loadTable(csvPath, "csv"); + }, + setup: () => { createCanvas(16, 16); }, + draw: () => { noLoop(); } + }); + + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { + // All 4 lines (header + 3 data) become data rows + expect(table.getRowCount()).toBe(4); + p5b.stop(); + done(); + }); + p5b.run(); + }); +}); + +describe("P5b Integration - drawingContext", () => { + it("drawingContext is defined after createCanvas and exposes canvas 2D API", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + global._dc = drawingContext; + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { + expect(global._dc).toBeDefined(); + expect(typeof global._dc.fillRect).toBe("function"); + expect(typeof global._dc.drawImage).toBe("function"); + expect(typeof global._dc.getImageData).toBe("function"); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("drawingContext can be used to draw directly onto the canvas", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + // Draw a red rectangle directly via the 2D context + drawingContext.fillStyle = "rgb(255, 0, 0)"; + drawingContext.fillRect(40, 40, 20, 20); + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (buffer) => { + const px = (x, y) => { const i = (y * 100 + x) * 4; return [buffer[i], buffer[i+1], buffer[i+2]]; }; + expect(px(50, 50)).toEqual([255, 0, 0]); // inside direct-drawn rect + expect(px(30, 30)).toEqual([0, 0, 0]); // outside + p5b.stop(); + done(); + }); + p5b.run(); + }); +}); + +describe("P5b Integration - Environment (Extended)", () => { + it("cursor() does not throw in headless environment", (done) => { + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + setup: () => { createCanvas(16, 16); }, + draw: () => { + cursor(HAND); + cursor(ARROW); + cursor(CROSS); + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("noCursor() does not throw in headless environment", (done) => { + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + setup: () => { createCanvas(16, 16); }, + draw: () => { noCursor(); noLoop(); } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("pixelDensity() returns 1 in headless environment", (done) => { + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + setup: () => { createCanvas(16, 16); }, + draw: () => { + global._pd = pixelDensity(); + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { + expect(global._pd).toBe(1); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("windowWidth and windowHeight match dimensions passed to createCanvas()", (done) => { + const p5b = new P5b({ + width: 200, height: 200, fps: 30, + setup: () => { createCanvas(150, 120); }, + draw: () => { + global._ww = windowWidth; + global._wh = windowHeight; + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { + expect(global._ww).toBe(150); + expect(global._wh).toBe(120); + p5b.stop(); + done(); + }); + p5b.run(); + }); +}); + +describe("P5b Integration - Accessibility", () => { + it("describe() does not throw in headless environment", (done) => { + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + setup: () => { createCanvas(16, 16); }, + draw: () => { + // Use global.describe explicitly to avoid shadowing by bun:test's describe + global.describe("A simple red square on a black background."); + background(0); fill(255, 0, 0); rect(4, 4, 8, 8); + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("describeElement() does not throw in headless environment", (done) => { + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + setup: () => { createCanvas(16, 16); }, + draw: () => { + describeElement("redSquare", "A red square."); + background(0); fill(255, 0, 0); rect(4, 4, 8, 8); + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("textOutput() does not throw in headless environment", (done) => { + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + setup: () => { createCanvas(16, 16); }, + draw: () => { + textOutput(); + background(0); + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); + + it("gridOutput() does not throw in headless environment", (done) => { + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + setup: () => { createCanvas(16, 16); }, + draw: () => { + gridOutput(); + background(0); + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { p5b.stop(); done(); }); + p5b.run(); + }); +}); + +describe("P5b Integration - loadFont", () => { + const fontPath = path.resolve(process.cwd(), "test/fixtures/font/SourceCodePro-Regular.ttf"); + + it("loadFont() in preload returns a p5.Font object", (done) => { + let font; + const p5b = new P5b({ + width: 100, height: 100, fps: 30, + preload: () => { font = loadFont(fontPath); }, + setup: () => { createCanvas(100, 100); }, + draw: () => { noLoop(); } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { + expect(font).toBeDefined(); + expect(typeof font).toBe("object"); + expect(font.font).toBeDefined(); // opentype.js font object + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("loadFont() allows textFont() to change rendering", (done) => { + let font; + const p5b = new P5b({ + width: 100, height: 100, fps: 30, + preload: () => { font = loadFont(fontPath); }, + setup: () => { createCanvas(100, 100); }, + draw: () => { + textFont(font); + textSize(20); + global._tw = textWidth("Hello"); + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { + expect(global._tw).toBeGreaterThan(0); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("loadFont() throws on missing file", (done) => { + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + preload: () => { loadFont("does-not-exist.ttf"); }, + setup: () => { createCanvas(16, 16); }, + draw: () => { noLoop(); } + }); + p5b.on("error", (e) => { + expect(e.error.message).toMatch(/Failed to load font/); + p5b.stop(); + done(); + }); + p5b.run(); + }); +}); + +describe("P5b Integration - push/pop", () => { + it("push() and pop() restore fill color", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + fill(255, 0, 0); + push(); + fill(0, 0, 255); + rect(0, 0, 40, 40); // blue rect + pop(); + rect(60, 60, 40, 40); // should be red (fill restored) + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (buffer) => { + const px = (x, y) => { const i = (y * 100 + x) * 4; return [buffer[i], buffer[i+1], buffer[i+2]]; }; + expect(px(20, 20)).toEqual([0, 0, 255]); // blue rect + expect(px(80, 80)).toEqual([255, 0, 0]); // red rect (fill restored by pop) + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("push() and pop() restore stroke weight", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + strokeWeight(1); + push(); + strokeWeight(20); + pop(); + // After pop, strokeWeight should be back to 1 + global._sw = drawingContext.lineWidth; + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { + expect(global._sw).toBe(1); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("nested push/pop restores state correctly", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + fill(255, 0, 0); + push(); + fill(0, 255, 0); + push(); + fill(0, 0, 255); + rect(10, 10, 20, 20); // blue + pop(); + rect(40, 40, 20, 20); // green + pop(); + rect(70, 70, 20, 20); // red + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (buffer) => { + const px = (x, y) => { const i = (y * 100 + x) * 4; return [buffer[i], buffer[i+1], buffer[i+2]]; }; + expect(px(20, 20)).toEqual([0, 0, 255]); + expect(px(50, 50)).toEqual([0, 255, 0]); + expect(px(80, 80)).toEqual([255, 0, 0]); + p5b.stop(); + done(); + }); + p5b.run(); + }); +}); + +describe("P5b Integration - Transforms", () => { + it("translate() shifts drawing origin", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + fill(255, 0, 0); noStroke(); + translate(30, 30); + rect(0, 0, 20, 20); // drawn at (30,30)-(50,50) in screen space + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (buffer) => { + const px = (x, y) => { const i = (y * 100 + x) * 4; return [buffer[i], buffer[i+1], buffer[i+2]]; }; + expect(px(40, 40)).toEqual([255, 0, 0]); // inside translated rect + expect(px(10, 10)).toEqual([0, 0, 0]); // origin, no rect here + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("rotate() rotates the coordinate system", (done) => { + // Draw a rect without and with rotation — buffers should differ + let noRotBuffer; + let drawCall = 0; + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + drawCall++; + background(0); + fill(255, 0, 0); noStroke(); + translate(50, 50); + if (drawCall === 1) { + rect(0, 0, 30, 10); + } else { + rotate(PI / 2); + rect(0, 0, 30, 10); + noLoop(); + } + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (buffer) => { + if (drawCall === 1) { + noRotBuffer = Buffer.from(buffer); + } else { + expect(Buffer.from(buffer).equals(noRotBuffer)).toBe(false); + p5b.stop(); + done(); + } + }); + p5b.run(); + }); + + it("scale() scales the coordinate system", (done) => { + // Draw same rect with scale(2) — covers more pixels than without + let normalBuffer; + let drawCall = 0; + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + drawCall++; + background(0); + fill(255, 0, 0); noStroke(); + if (drawCall === 1) { + rect(10, 10, 20, 20); + } else { + scale(2); + rect(10, 10, 20, 20); + noLoop(); + } + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (buffer) => { + if (drawCall === 1) { + normalBuffer = Buffer.from(buffer); + } else { + expect(Buffer.from(buffer).equals(normalBuffer)).toBe(false); + p5b.stop(); + done(); + } + }); + p5b.run(); + }); + + it("push/pop wraps transforms correctly", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + fill(255, 0, 0); noStroke(); + push(); + translate(50, 50); + rect(0, 0, 20, 20); // at (50,50) + pop(); + rect(0, 0, 20, 20); // back at (0,0) + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (buffer) => { + const px = (x, y) => { const i = (y * 100 + x) * 4; return [buffer[i], buffer[i+1], buffer[i+2]]; }; + expect(px(10, 10)).toEqual([255, 0, 0]); // rect at origin + expect(px(60, 60)).toEqual([255, 0, 0]); // rect at translated origin + p5b.stop(); + done(); + }); + p5b.run(); + }); +}); + +describe("P5b Integration - colorMode", () => { + it("colorMode(HSB) allows HSB color specification", (done) => { + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + background(0); + colorMode(HSB); + fill(0, 100, 100); // pure red in HSB + noStroke(); + rect(20, 20, 60, 60); + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (buffer) => { + const px = (x, y) => { const i = (y * 100 + x) * 4; return [buffer[i], buffer[i+1], buffer[i+2]]; }; + const [r, g, b] = px(50, 50); + expect(r).toBeGreaterThan(200); // red channel high + expect(g).toBeLessThan(50); // green low + expect(b).toBeLessThan(50); // blue low + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("colorMode(RGB) and colorMode(HSB) produce different colors for same values", (done) => { + let rgbBuffer; + let drawCall = 0; + const p5b = new P5b({ + width: 100, height: 100, fps: 60, + setup: () => { createCanvas(100, 100); }, + draw: () => { + drawCall++; + background(0); + noStroke(); + if (drawCall === 1) { + colorMode(RGB); + fill(120, 100, 100); + } else { + colorMode(HSB); + fill(120, 100, 100); + noLoop(); + } + rect(20, 20, 60, 60); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (buffer) => { + if (drawCall === 1) { + rgbBuffer = Buffer.from(buffer); + } else { + expect(Buffer.from(buffer).equals(rgbBuffer)).toBe(false); + p5b.stop(); + done(); + } + }); + p5b.run(); + }); +}); + +describe("P5b Integration - Property Getters", () => { + it("frameCount increments each draw call", (done) => { + const counts = []; + const p5b = new P5b({ + width: 16, height: 16, fps: 60, + setup: () => { createCanvas(16, 16); }, + draw: () => { + counts.push(frameCount); + if (frameCount >= 3) noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { + if (!p5b._myP5?.isLooping()) { + expect(counts.length).toBeGreaterThanOrEqual(3); + expect(counts[0]).toBe(1); + expect(counts[1]).toBe(2); + expect(counts[2]).toBe(3); + p5b.stop(); + done(); + } + }); + p5b.run(); + }); + + it("width and height reflect the createCanvas dimensions", (done) => { + const p5b = new P5b({ + width: 50, height: 50, fps: 30, + setup: () => { createCanvas(80, 60); }, + draw: () => { + global._w = width; + global._h = height; + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { + expect(global._w).toBe(80); + expect(global._h).toBe(60); + p5b.stop(); + done(); + }); + p5b.run(); + }); + + it("frameRate getter returns a number", (done) => { + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + setup: () => { createCanvas(16, 16); }, + draw: () => { + global._fr = frameRate(); + noLoop(); + } + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { + expect(typeof global._fr).toBe("number"); + p5b.stop(); + done(); + }); + p5b.run(); + }); +}); + +describe("P5b Integration - navigator.userAgent", () => { + it("userAgent contains p5b-dom and version string", (done) => { + let capturedUA = null; + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + setup: () => { + createCanvas(16, 16); + capturedUA = navigator.userAgent; + }, + draw: () => { noLoop(); }, + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", () => { + expect(capturedUA).toMatch(/^p5b-dom\/\d+\.\d+\.\d+$/); + p5b.stop(); + done(); + }); + p5b.run(); + }); +}); + +describe("P5b Integration - run/stop/run lifecycle", () => { + it("emits frames correctly after stop() and run() again", (done) => { + let frameCount = 0; + let timeoutRan = false; + + const p5b = new P5b({ + width: 16, height: 16, fps: 30, + setup: () => { createCanvas(16, 16); }, + draw: () => { background(0, 255, 0); }, + }); + + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (pixels) => { + frameCount++; + expect(pixels[0]).toBe(0); + expect(pixels[1]).toBe(255); + expect(pixels[2]).toBe(0); + p5b.stop(); + }); + + p5b.run(); + + setTimeout(() => { + p5b.run(); + timeoutRan = true; + }, 2000); + + setTimeout(() => { + expect(frameCount).toBe(2); + expect(timeoutRan).toBe(true); + done(); + }, 4000); + }, 10000); +}); + +describe("P5b Integration - toFrame loadPixels happy path", () => { + it("emits correct RGBA pixels when canvas matches p5b dimensions", (done) => { + const WIDTH = 16; + const HEIGHT = 16; + const p5b = new P5b({ + width: WIDTH, height: HEIGHT, fps: 30, + setup: () => { createCanvas(WIDTH, HEIGHT); background(255, 0, 0); }, + draw: () => { noLoop(); }, + }); + p5b.on("error", (e) => { p5b.stop(); done(e.error); }); + p5b.on("frame", (pixels) => { + // Red background: R=255, G=0, B=0, A=255 + expect(pixels[0]).toBe(255); + expect(pixels[1]).toBe(0); + expect(pixels[2]).toBe(0); + expect(pixels[3]).toBe(255); + expect(pixels.length).toBe(WIDTH * HEIGHT * 4); + p5b.stop(); + done(); + }); + p5b.run(); + }); +}); diff --git a/test/integration/sketches.test.js b/test/integration/sketches.test.js index a547317..4ca7f0b 100644 --- a/test/integration/sketches.test.js +++ b/test/integration/sketches.test.js @@ -1,9 +1,9 @@ -/* eslint-disable no-undef */ + const { describe, it, expect } = require("bun:test"); const path = require("path"); const { P5b } = require("../../p5b"); -const sketchesDir = path.join(__dirname, "../fixtures/sketches"); +const sketchesDir = path.join(process.cwd(), "test/fixtures/sketches"); describe("P5b Real Sketch - Shapes", () => { it("should render shapes sketch successfully", (done) => { @@ -144,3 +144,174 @@ describe("P5b Real Sketch - Graphics Pooling", () => { p5b.run(); }); }); + +describe("P5b Real Sketch - loadImage", () => { + it("should render image with correct pixel colors at 32x32", (done) => { + const p5b = new P5b({ + sketchPath: path.join(sketchesDir, "loadimage.js"), + width: 32, + height: 32, + fps: 30 + }); + + p5b.on("error", (err) => { + p5b.stop(); + done(err.error); + }); + + p5b.on("frame", (buffer) => { + expect(buffer).toBeInstanceOf(Uint8Array); + expect(buffer.length).toBe(32 * 32 * 4); + + const px = (x, y) => { + const i = (y * 32 + x) * 4; + return [buffer[i], buffer[i + 1], buffer[i + 2]]; + }; + + // Verify image content is rendered (not empty) + const topLeft = px(0, 0); + expect(topLeft[0]).toBeGreaterThan(200); // Image has color content + expect(topLeft[1]).toBeGreaterThan(200); + expect(topLeft[2]).toBeGreaterThan(200); + + // Verify specific positions have image content (not blue) + const midImage = px(17, 14); + expect(midImage[0]).toBeGreaterThan(190); + expect(midImage[1]).toBeGreaterThan(190); + expect(midImage[2]).toBeGreaterThan(180); + + // Verify blue background shows through in the bottom region + // (canvas is 7954x7954, image is 7954x5305, so y > 21 in output is blue) + const corner = px(31, 31); + expect(corner).toEqual([0, 0, 255]); + + // Check a position definitely in blue region (y >= 22) + const bottomArea = px(22, 22); + expect(bottomArea).toEqual([0, 0, 255]); + + p5b.stop(); + done(); + }); + + p5b.run(); + }); +}); + +describe("P5b Real Sketch - Scaling", () => { + it("should scale 4x4 canvas to 2x2 output", (done) => { + const p5b = new P5b({ + sketchPath: path.join(sketchesDir, "scaling.js"), + width: 2, + height: 2, + fps: 30 + }); + + p5b.on("error", (err) => { + p5b.stop(); + done(err.error); + }); + + p5b.on("frame", (buffer) => { + expect(buffer).toBeInstanceOf(Uint8Array); + expect(buffer.length).toBe(2 * 2 * 4); + + const px = (x, y) => { + const i = (y * 2 + x) * 4; + return [buffer[i], buffer[i + 1], buffer[i + 2]]; + }; + + expect(px(0, 0)).toEqual([255, 0, 0]); // red - top-left 2x2 region scaled to 1x1 + expect(px(1, 0)).toEqual([255, 255, 255]); // white - rest is white + expect(px(0, 1)).toEqual([255, 255, 255]); // white + expect(px(1, 1)).toEqual([255, 255, 255]); // white + + p5b.stop(); + done(); + }); + + p5b.run(); + }); +}); + +describe("P5b Real Sketch - Globals", () => { + it("should verify all math globals work correctly in sketch", (done) => { + const p5b = new P5b({ + sketchPath: path.join(sketchesDir, "globals.js"), + width: 16, + height: 16, + fps: 30 + }); + p5b.on("error", (err) => { + p5b.stop(); + done(err.error); + }); + p5b.on("frame", (buffer) => { + expect(global.results.pi).toBe(Math.PI); + expect(global.results.two_pi).toBe(Math.PI * 2); + expect(global.results.half_pi).toBe(Math.PI / 2); + expect(global.results.quarter_pi).toBe(Math.PI / 4); + expect(global.results.tau).toBe(Math.PI * 2); + expect(global.results.degrees).toBe("degrees"); + expect(global.results.radians).toBe("radians"); + expect(global.results.abs).toBe(5); + expect(global.results.ceil).toBe(5); + expect(global.results.floor).toBe(4); + expect(global.results.round).toBe(5); + expect(global.results.pow).toBe(8); + expect(global.results.sqrt).toBe(4); + expect(global.results.exp).toBeCloseTo(Math.E, 5); + expect(global.results.log).toBe(1); + expect(global.results.max).toBe(5); + expect(global.results.min).toBe(1); + expect(global.results.sq).toBe(16); + expect(global.results.sq_neg).toBe(9); + expect(global.results.mag).toBe(5); + expect(global.results.fract).toBe(0.5); + expect(global.results.fract_int).toBe(0); + expect(global.results.fract_neg).toBe(0.5); + expect(global.results.map).toBe(500); + expect(global.results.lerp).toBe(50); + expect(global.results.constrain).toBe(100); + expect(global.results.constrain_in_range).toBe(50); + expect(global.results.dist).toBe(5); + expect(global.results.dist_3d).toBeCloseTo(5.385, 0.01); + expect(typeof global.results.random).toBe("number"); + expect(typeof global.results.noise).toBe("number"); + expect(global.results.norm).toBe(0.4); + expect(global.results.abs_neg).toBe(1); + expect(global.results.ceil_neg).toBe(-1); + expect(global.results.floor_neg).toBe(-2); + expect(global.results.dist_identical).toBe(0); + expect(global.results.dist_identical_3d).toBe(0); + expect(global.results.lerp_start).toBe(0); + expect(global.results.lerp_stop).toBe(5); + expect(global.results.lerp_avg).toBe(2.5); + p5b.stop(); + done(); + }); + p5b.run(); + }); +}); + +describe("P5b Real Sketch - global sketch", () => { + it("should verify global scope variables are bound in sketch", (done) => { + const p5b = new P5b({ + sketchPath: path.join(sketchesDir, "global-scope.js"), + width: 16, + height: 16, + fps: 30 + }); + p5b.on("error", (err) => { + p5b.stop(); + done(err.error); + }); + p5b.on("frame", (buffer) => { + expect(global.found_hello).toBe("I am a global variable"); + expect(global.found_count).toBe(42); + p5b.stop(); + done(); + }); + + p5b.run(); + }); +}); diff --git a/test/p5b.test.js b/test/p5b.test.js index 154300b..ad38b1b 100644 --- a/test/p5b.test.js +++ b/test/p5b.test.js @@ -1,6 +1,6 @@ const { describe, it, expect } = require("bun:test"); +const path = require("path"); const { P5b, P5B_DEFAULTS } = require("../p5b.js"); -const { P5bDOM } = require("../p5b-dom.js"); describe("P5b Exports", () => { it("should export P5b class", () => { @@ -86,7 +86,7 @@ describe("P5b Configuration Validation", () => { }); describe("P5b Instance Management", () => { - it("should throw if run() is called twice without stop()", (done) => { + it("should throw if run() is called after remove()", (done) => { const p5b = new P5b({ width: 32, height: 32, setup: () => { createCanvas(64, 64); }, @@ -94,16 +94,15 @@ describe("P5b Instance Management", () => { }); p5b.on("frame", () => { - // Try to call run again while already running - expect(() => p5b.run()).toThrow("already running"); - p5b.stop(); + p5b.remove(); + expect(() => p5b.run()).toThrow("removed"); done(); }); p5b.run(); }); - it("should properly cleanup when stopped", (done) => { + it("should properly cleanup when removed", (done) => { const p5b = new P5b({ width: 32, height: 32, setup: () => { createCanvas(64, 64); }, @@ -111,9 +110,9 @@ describe("P5b Instance Management", () => { }); p5b.on("frame", () => { - p5b.stop(); - - // After stop, internal state should be cleared + p5b.remove(); + + // After remove, internal state should be cleared expect(p5b._myP5).toBeNull(); expect(p5b._destCanvas).toBeNull(); expect(p5b._gfxActive.length).toBe(0); @@ -250,6 +249,53 @@ describe("P5b Global Bindings", () => { p5b.run(); }); + it("should load a valid font file successfully", (done) => { + const fontPath = path.join(process.cwd(), "test/fixtures/font/SourceCodePro-Regular.ttf"); + let loadedFont = null; + + const p5b = new P5b({ + width: 32, height: 32, + setup: () => { + createCanvas(64, 64); + loadedFont = loadFont(fontPath); + }, + draw: () => { background(100); } + }); + + p5b.on("frame", () => { + expect(loadedFont).toBeDefined(); + expect(loadedFont.font).toBeDefined(); + expect(loadedFont.font.names).toBeDefined(); + p5b.stop(); + done(); + }); + + p5b.run(); + }); + + it("should throw with friendly message when font file not found", (done) => { + const p5b = new P5b({ + width: 32, height: 32, + setup: () => { + createCanvas(64, 64); + try { + loadFont("/nonexistent/path/to/font.ttf"); + } catch (error) { + expect(error.message).toContain("Failed to load font"); + expect(error.message).toContain("file not found"); + } + }, + draw: () => { background(100); } + }); + + p5b.on("frame", () => { + p5b.stop(); + done(); + }); + + p5b.run(); + }); + it("should track graphics in pool after removal", (done) => { let frameCount = 0; const p5b = new P5b({ diff --git a/test/perf/mem-leak.test.js b/test/perf/mem-leak.test.js new file mode 100644 index 0000000..ae96627 --- /dev/null +++ b/test/perf/mem-leak.test.js @@ -0,0 +1,67 @@ +/* eslint-disable no-console */ +/** + * Memory leak test for p5b.js stop()/run() lifecycle. + * + * Run with: bun test test/perf/mem-leak.test.js + * + * Verifies that repeated stop()/run() cycles on a single P5b instance + * do not cause unbounded RSS growth. Uses a large canvas (4096x4096) + * to stress-test Cairo surface reuse. + */ + +const { describe, it, expect } = require("bun:test"); +const { P5b } = require("../../p5b.js"); + +const CANVAS_SIZE = 4096; +const CYCLE_INTERVAL_MS = 200; +const DURATION_MS = 30_000; +const WARM_UP_MS = 2_000; +// Baseline (~184MB for 4096x4096) + 50MB headroom +const RSS_GROWTH_LIMIT_MB = 50; + +function mbRSS() { + return process.memoryUsage().rss / 1024 / 1024; +} + +describe("Memory - stop()/run() cycles do not leak", () => { + it("RSS growth stays under 50MB over 30s of stop/run cycles at 4096x4096", (done) => { + let baselineMB = null; + let cycles = 0; + + const p5b = new P5b({ + width: 64, height: 64, fps: 60, + setup: () => { createCanvas(CANVAS_SIZE, CANVAS_SIZE); }, + draw: () => { background(100, 150, 200); }, + }); + + p5b.on("error", (e) => { p5b.remove(); done(e.error); }); + p5b.run(); + + const cycleTimer = setInterval(() => { + p5b.stop(); + p5b.run(); + cycles++; + }, CYCLE_INTERVAL_MS); + + // Capture baseline after warmup + setTimeout(() => { + baselineMB = mbRSS(); + }, WARM_UP_MS); + + setTimeout(() => { + clearInterval(cycleTimer); + p5b.remove(); + + const finalMB = mbRSS(); + const growth = finalMB - baselineMB; + + console.log(` cycles: ${cycles}`); + console.log(` baseline: ${baselineMB.toFixed(1)}MB`); + console.log(` final: ${finalMB.toFixed(1)}MB`); + console.log(` growth: ${growth.toFixed(1)}MB (limit: ${RSS_GROWTH_LIMIT_MB}MB)`); + + expect(growth).toBeLessThan(RSS_GROWTH_LIMIT_MB); + done(); + }, DURATION_MS); + }, 35_000); +}); diff --git a/test/perf/perf.test.js b/test/perf/perf.test.js new file mode 100644 index 0000000..d2aa5a9 --- /dev/null +++ b/test/perf/perf.test.js @@ -0,0 +1,61 @@ +/* eslint-disable no-console */ +/** + * Performance tests for p5b.js toFrame() paths. + * + * Run with: bun test test/perf/perf.test.js + * + * Compares happy path (canvas dims == p5b dims, uses loadPixels) + * vs scale path (canvas dims != p5b dims, uses drawImage + BGRA swap). + * The happy path should be measurably faster. + */ + +const { P5b } = require("../../p5b.js"); + +const FRAMES = 200; +const WARM_UP = 20; + +function runBench(p5bInstance) { + return new Promise((resolve, reject) => { + let count = 0; + let start = null; + + p5bInstance.on("error", (e) => { p5bInstance.stop(); reject(e.error); }); + p5bInstance.on("frame", () => { + count++; + if (count === WARM_UP) { + start = Date.now(); + } + if (count === WARM_UP + FRAMES) { + const elapsed = Date.now() - start; + p5bInstance.stop(); + resolve(elapsed / FRAMES); + } + }); + p5bInstance.run(); + }); +} + +describe("Performance - toFrame() happy path vs scale path", () => { + it("happy path is faster than scale path", async () => { + const happyP5b = new P5b({ + width: 512, height: 512, fps: 10000, + setup: () => { createCanvas(512, 512); }, + draw: () => { background(100, 150, 200); }, + }); + + const scaleP5b = new P5b({ + width: 256, height: 256, fps: 10000, + setup: () => { createCanvas(512, 512); }, + draw: () => { background(100, 150, 200); }, + }); + + const happyMs = await runBench(happyP5b); + const scaleMs = await runBench(scaleP5b); + + console.log(` happy path: ${happyMs.toFixed(3)}ms/frame`); + console.log(` scale path: ${scaleMs.toFixed(3)}ms/frame`); + console.log(` speedup: ${(scaleMs / happyMs).toFixed(2)}x`); + + expect(happyMs).toBeLessThan(scaleMs); + }, 60000); +});