├── .travis.yml ├── LICENSE ├── README.md ├── index └── package.json /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "4.0" 5 | - "5.0" 6 | - "6.0" 7 | 8 | branches: 9 | only: 10 | - master 11 | 12 | notifications: 13 | email: 14 | on_success: change 15 | on_failure: always 16 | 17 | script: 18 | - npm run -s test 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Josiah Savary 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pico-test 2 | 3 |

4 | 5 | npm version 6 | 7 | 8 | build status 9 | 10 | 11 | code style 12 | 13 |

14 | 15 | > **Note:** This project is still in its initial stages, so I'd love feedback about the API and issue reports. 16 | 17 | ### Intro 18 | 19 | PICO-8 is great but debugging your code in this little vm can be a chore. 20 | 21 | If you're tired of riddling your carts with `printh`s or have given up on test-driven development, this tool should help you out. 22 | 23 | ### Installation 24 | 25 | npm i -g pico-test 26 | 27 | > **Note:** you can also download it directly from the [releases section](https://github.com/jozanza/pico-test/releases) 28 | 29 | ### Usage 30 | 31 | Copy/paste the following snippet into the cart you wish to test: 32 | 33 | ```lua 34 | function test(title,f) 35 | local desc=function(msg,f) 36 | printh('⚡:desc:'..msg) 37 | f() 38 | end 39 | local it=function(msg,f) 40 | printh('⚡:it:'..msg) 41 | local xs={f()} 42 | for i=1,#xs do 43 | if xs[i] == true then 44 | printh('⚡:assert:true') 45 | else 46 | printh('⚡:assert:false') 47 | end 48 | end 49 | printh('⚡:it_end') 50 | end 51 | printh('⚡:test:'..title) 52 | f(desc,it) 53 | printh('⚡:test_end') 54 | end 55 | ``` 56 | 57 | Next, be sure PICO-8 is aliased properly in your terminal. You may have to do something like the following: 58 | 59 | alias pico-8='/Applications/PICO-8.app/Contents/MacOS/pico8' 60 | 61 | Last, run Pico-8 from your terminal and pipe its output to `pico-test`. 62 | 63 | pico-8 | pico-test 64 | 65 | Each time your run your cart, test results will be printed to `stdout`. Now, you just have to write some tests! :) 66 | 67 | ### API 68 | 69 | `pico-test`'s api is will be pretty familiar if you've ever used [mocha](https://mochajs.org/). There are only 3 functions to learn: `test()`, `desc()`, and `it()` 70 | 71 | #### test(title:string, fn:function) 72 | 73 | initiates testing, wraps around test descriptions and tests, providing the callback `fn` with two args: `desc` and `it` – the other two functions in this API. 74 | 75 | | Type | Param | Description | 76 | |----------|-------|-------------| 77 | | String | title | title of test suite 78 | | Function | fn | callback to call with `desc` and `it` 79 | 80 | #### desc(description:string, fn:function) 81 | 82 | Describes a set of tests. This function is applied as the first argument of the callback function passed to `test` 83 | 84 | | Type | Param | Description | 85 | |----------|-------------|-------------| 86 | | String | description | description for tests to be run inside of param `fn` 87 | | Function | fn | callback to call with `desc` and `it` 88 | 89 | 90 | #### it(message:string, fn:function) 91 | 92 | Returns one or more boolean values representing test assertions. all returned values must be `true` or your test will fail. This function is applied as the second argument of the callback function passed to `test` 93 | 94 | | Type | Param | Description | 95 | |----------|---------|-------------| 96 | | String | message | message starting with "should" 97 | | Function | fn | callback to return assertions from 98 | 99 | 100 | ### Example 101 | 102 | Here's what it looks like in action: 103 | 104 | ```lua 105 | -- here's an object with methods we want to test 106 | local math={ 107 | gt=function(a,b) return a>b end, 108 | lt=function(a,b) return a operator', function() 120 | return gt(1,0) 121 | end) 122 | end) 123 | 124 | desc('math.lt()', function() 125 | local lt = math.lt 126 | it('should return type boolean',function() 127 | return 'boolean' == type(lt(1,0)) 128 | end) 129 | it('should give same result as < operator',function() 130 | return lt(1, 0) == false 131 | end) 132 | end) 133 | 134 | desc('math.mul()', function() 135 | local mul = math.mul 136 | it('should return type number', function() 137 | local a = rnd(time()) 138 | local b = rnd(time()) 139 | return 'number' == type(mul(a,b)) 140 | end) 141 | it('should give same result as * operator', function() 142 | local x=rnd(time()) 143 | return 144 | x*1 == mul(x,1), 145 | x*2 == mul(x,2), 146 | x*3 == mul(x,3) 147 | end) 148 | end) 149 | 150 | desc('math.div()', function() 151 | local div = math.div 152 | it('should return type number', function() 153 | local a = rnd(time()) 154 | local b = rnd(time()) 155 | return 'number' == type(div(a,b)) 156 | end) 157 | it('should give same result as / operator', function() 158 | local x=1+rnd(time()) 159 | return 160 | x/1 == div(x,1), 161 | x/2 == div(x,2), 162 | x/3 == div(x,3) 163 | end) 164 | end) 165 | 166 | end) 167 | ``` 168 | 169 | ### License 170 | 171 | Copyright (c) 2015 Josiah Savary. Made available under The MIT License (MIT). 172 | -------------------------------------------------------------------------------- /index: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict' 3 | 4 | /** 5 | * Rolled my own tiny ANSI formatting lib :) 6 | */ 7 | const ANSI = { 8 | _: { 9 | // font style 10 | reset: 0, 11 | bold: 1, 12 | normal_color: 22, 13 | // colors 14 | red: 31, 15 | yellow: 33, 16 | green: 36, 17 | // CSI 18 | cursor_reset: '1d', 19 | display_erase: '2J' 20 | }, 21 | esc: x => `\x1b[${x}${typeof x === 'number' ? 'm' : ''}`, 22 | code: x => ANSI._[x], 23 | codes () { 24 | return Array.prototype.slice.call(arguments) 25 | .map(compose(ANSI.esc, ANSI.code)) 26 | .join('') 27 | }, 28 | unesc: x => 29 | (x || '') + 30 | ANSI.esc(ANSI._.reset) + 31 | ANSI.esc(ANSI._.normal_color), 32 | formatRaw: x => y => ANSI.unesc(x + (y || '')) 33 | } 34 | ANSI.format = compose( 35 | ANSI.formatRaw, 36 | ANSI.codes 37 | ) 38 | 39 | /** 40 | * 41 | * Parses a stream looking for commands, 42 | * triggers store dispatches, and prints 43 | * to the console 44 | * 45 | * @param {Function} dispatch - dispatches actions to the store 46 | * @param {Function} write - prints to stdout 47 | * @return {Function} handler for printing or dispatching actions 48 | */ 49 | const parseInput = curry((dispatch, write) => { 50 | let line = '' 51 | let concat = false 52 | return x => { 53 | if (x[0] === '⚡') concat = true 54 | if (!concat) write(x) 55 | else { 56 | line += x 57 | if (x === '\r\n') { 58 | const args = line.split(':') 59 | const action = { 60 | type: args[1].trim(), 61 | payload: !args[2] ? [] : args[2].split(',').map(x => { 62 | try { 63 | return JSON.parse(x) 64 | } catch (err) { 65 | return x.trim() 66 | } 67 | }) 68 | } 69 | dispatch(action) 70 | concat = false 71 | line = '' 72 | } 73 | } 74 | } 75 | }) 76 | 77 | /** 78 | * 79 | * Prints values returned from reporter to stdout 80 | * 81 | * @param {Function} reporter - converts dispatched actions to printable strings 82 | * @param {Object} getState - retrieves the current application state 83 | * @param {Function} write - prints to stdout 84 | * @param {Object} action - the most recently dispatched action 85 | */ 86 | const printOutput = curry((reporter, getState, write, action) => { 87 | const out = reporter(getState(), action) 88 | write(typeof out === 'string' ? out : '') 89 | }) 90 | 91 | /** 92 | * 93 | * Contains methods for interacting with the application state 94 | * 95 | * @param {Object} initialState - the initial application state 96 | * @param {Function} reducer - handles action dispatches 97 | * @return {Object} a store object 98 | */ 99 | function createStore (initialState, reducer) { 100 | let state = initialState 101 | let subscribers = [] 102 | return { 103 | getState: () => state, 104 | subscribe: f => { 105 | subscribers.push(f) 106 | return () => subscribers.splice(subscribers.indexOf(f), 1) 107 | }, 108 | dispatch: action => { 109 | state = reducer(state, action) 110 | subscribers.forEach((f) => f(action)) 111 | } 112 | } 113 | } 114 | 115 | /** 116 | * 117 | * Replaces the write method of a stream 118 | * 119 | * @param {Stream} stream - the stream to be mutated 120 | * @param {Function} f - new write method 121 | * @return {Function} the original stream.write method 122 | */ 123 | function mutateWrite (stream, f) { 124 | const write = stream.write.bind(stream) 125 | stream.write = f(write) 126 | return write 127 | } 128 | 129 | /** 130 | * 131 | * Updates the state object based on dispatched actions 132 | * 133 | * @param {Object} state - the current application state 134 | * @param {Object} action - the most recently dispatched action 135 | * @return {Object} the next state 136 | */ 137 | function reducer (state, action) { 138 | let nextState = Object.assign({}, state) 139 | let _ = action.payload 140 | const i = nextState.suites.length - 1 141 | const j = !nextState.suites[i] 142 | ? null 143 | : nextState.suites[i].cases.length - 1 144 | switch (action.type) { 145 | case 'test': 146 | // payload: [] 147 | nextState = { 148 | title: _[0], 149 | lastRun: Date.now(), 150 | allPassed: true, 151 | done: false, 152 | assertCount: 0, 153 | failCount: 0, 154 | suites: [] 155 | } 156 | break 157 | case 'test_end': 158 | // payload: [] 159 | nextState.done = true 160 | break 161 | case 'desc': 162 | // payload: [name:string] 163 | nextState.suites.push({ 164 | name: _[0], 165 | cases: [] 166 | }) 167 | break 168 | case 'it': 169 | // payload: [name:string] 170 | nextState.suites[i].cases.push({ 171 | name: _[0], 172 | assertions: [] 173 | }) 174 | break 175 | case 'perf': 176 | // payload: [] 177 | nextState.suites[i].cases[j].started = Date.now() 178 | break 179 | case 'perf_end': 180 | // payload: [] 181 | nextState.suites[i].cases[j].stopped = Date.now() 182 | break 183 | case 'assert': 184 | // payload: [yepnope:boolean] 185 | nextState.suites[i].cases[j].assertions.push(_[0]) 186 | nextState.assertCount += 1 187 | if (!_[0]) { 188 | nextState.allPassed = false 189 | nextState.failCount += 1 190 | } 191 | break 192 | default: break 193 | } 194 | return nextState 195 | } 196 | 197 | /** 198 | * 199 | * Returns text to be written to stdout 200 | * 201 | * @param {Object} state - the current application state 202 | * @param {Object} action - the most recently dispatched action 203 | * @return {String} text to write to stdout 204 | */ 205 | function reporter (state, action) { 206 | let out 207 | let _ = action.payload 208 | const format = ANSI.format 209 | switch (action.type) { 210 | case 'test': 211 | // payload: [] 212 | out = 213 | format('cursor_reset', 'display_erase')() + 214 | format('bold')(`🎮 [PICO-TEST] ${_[0].toUpperCase()} `) + 215 | `@ ${(new Date()).toLocaleString()}` 216 | break 217 | case 'test_end': 218 | out = `${state.assertCount} tests complete. ${state.assertCount - state.failCount} passing.\n` 219 | if (!state.allPassed) out = format('bold', 'red')(`\n\n✖ ${out}`) 220 | else out = format('bold', 'green')(`\n\n✔ ${out}`) 221 | break 222 | case 'desc': 223 | // payload: [name:string] 224 | out = `\n\n+ ${_[0]}` 225 | break 226 | case 'it_end': 227 | const test = state.suites[state.suites.length - 1] 228 | const kase = test.cases[test.cases.length - 1] 229 | // const duration = kase.stopped - kase.started 230 | const failed = kase.assertions.reduce((next, x) => next || !x, false) 231 | const fails = kase.assertions.map((passed, i) => passed 232 | ? '' 233 | : `\n • failed assertion #${i + 1}` 234 | ).join('') 235 | out = '\n ' + [ 236 | (failed ? '✖' : '✔'), 237 | kase.name, 238 | ''// `(${!duration ? '<1' : duration}ms)\n`, 239 | ].join(' ') + fails 240 | if (failed) out = format('red')(out) 241 | else out = format('green')(out) 242 | break 243 | default: break 244 | } 245 | return out 246 | } 247 | 248 | /** 249 | * 250 | * A classic currying function 251 | * 252 | * @param {Function} f - function to be made curryable 253 | * @param {Number} [len=f.length] - number of params to curry 254 | * @return {Function} a curryable version of the original function 255 | */ 256 | function curry (f, len) { 257 | return function curryable () { 258 | const args = Array.prototype.slice.call(arguments) 259 | const arity = len || f.length 260 | return args.length >= arity 261 | ? f.apply(this, args.slice(0, arity)) 262 | : function () { 263 | const _args = Array.prototype.slice.call(arguments) 264 | const nextArgs = args.concat(_args.length ? _args : [undefined]) 265 | return curryable.apply(this, nextArgs) 266 | } 267 | } 268 | } 269 | 270 | /** 271 | * 272 | * A classic FP compose function 273 | * 274 | * @param {Function[]} ...fs - any number of unary functions (except the last which may receive muliple args) 275 | * @return {Function} a composed function that chains all `fs` together from right to left 276 | */ 277 | function compose () { 278 | const fs = Array.prototype.slice.call(arguments) 279 | return function composed () { 280 | const args = Array.prototype.slice.call(arguments) 281 | let n = fs.length 282 | let x 283 | do { 284 | --n 285 | x = fs[n].apply(this, n < fs.length - 1 ? [x] : args) 286 | } while (n) 287 | return x 288 | } 289 | } 290 | 291 | /** 292 | * 293 | * Rolled a mini testing framework too to avoid dependencies :) 294 | * 295 | * @param {Function} f - test callback, receives args desc:Function, it:Function, and assert:Function 296 | */ 297 | function test (f) { 298 | const assert = require('assert') 299 | const clear = ANSI.format('cursor_reset', 'display_erase') 300 | const norm = ANSI.format('normal_color') 301 | const bold = ANSI.format('bold') 302 | const pass = ANSI.format('green') 303 | const fail = ANSI.format('red') 304 | const pend = ANSI.format('yellow') 305 | let passing = 0 306 | let pending = 0 307 | const desc = (s, f) => { 308 | console.log(norm(`\n+ ${s}`)) 309 | f() 310 | } 311 | const it = (s, f, e) => assert.doesNotThrow(() => { 312 | if (!f) { 313 | ++pending 314 | return console.log(pend(` - it ${s} (pending)`)) 315 | } 316 | try { 317 | f() 318 | ++passing 319 | console.log(pass(` ✔ it ${s}`)) 320 | } catch (err) { 321 | console.log(fail(` ✖ it ${s}`)) 322 | throw err 323 | } 324 | }, e) 325 | console.log(clear()) 326 | console.log(bold('Running tests...')) 327 | f(desc, it, assert) 328 | console.log(bold(`\n...done! ${passing + pending} total tests. ${passing} passing. ${pending} pending.`)) 329 | console.log(`\nCompleted @ ${(new Date()).toLocaleString()}\n`) 330 | } 331 | 332 | /** 333 | * 334 | * Listens for inupt from pico-8 335 | * 336 | * @param {Object} store - an interface for managing the app state 337 | */ 338 | function run (store) { 339 | const parse = parseInput(store.dispatch) 340 | const write = mutateWrite(process.stdout, parse) 341 | const print = printOutput(reporter, store.getState, write) 342 | const opts = { 343 | input: process.stdin, 344 | output: process.stdout, 345 | terminal: true 346 | } 347 | // Write to stdout whenever the state changes 348 | store.subscribe(print) 349 | // read from stdin and write to stdout in tty mode 350 | require('readline').createInterface(opts) 351 | } 352 | 353 | /** 354 | * 355 | * CLI args 356 | * 357 | * @type {String[]} 358 | */ 359 | const argv = process.argv 360 | 361 | /** 362 | * 363 | * Is running in TEST_MODE 364 | * 365 | * @type {Boolean} 366 | */ 367 | const TEST_MODE = !!argv.filter(x => x.toLowerCase() === 'test').length 368 | 369 | /** 370 | * Do Stuff >:3 371 | */ 372 | if (!TEST_MODE) { 373 | run( 374 | createStore({ 375 | title: '', 376 | lastRun: null, 377 | allPassed: true, 378 | done: false, 379 | assertCount: 0, 380 | failCount: 0, 381 | suites: [] 382 | }, reducer) 383 | ) 384 | } else { 385 | test((desc, it, assert) => { 386 | desc('parseInput()', () => { 387 | it('should log strean data while a is not being parsed') 388 | it('should not stream data while a command is being parsed') 389 | it('should dispatch an action when command parsing is done') 390 | }) 391 | desc('printOutput()', () => { 392 | it('should log an action') 393 | }) 394 | desc('createStore()', () => { 395 | it('should return a store object') 396 | }) 397 | desc('store', () => { 398 | it('should get the current state') 399 | it('should dispatch actions to the reducer') 400 | it('should add subscribers') 401 | it('should remove subscribers') 402 | }) 403 | desc('mutateWrite()', () => { 404 | it('should mutate stream\'s write method') 405 | it('should return stream\'s original write method') 406 | }) 407 | desc('reducer()', () => { 408 | it('should handle "test" actions') 409 | it('should handle "test_end" actions') 410 | it('should handle "desc" actions') 411 | it('should handle "it" actions') 412 | it('should handle "perf" actions') 413 | it('should handle "perf_end" actions') 414 | it('should handle "assert" actions') 415 | }) 416 | desc('reporter()', () => { 417 | it('should report on "test" actions') 418 | it('should report on "test_end" actions') 419 | it('should report on "desc" actions') 420 | it('should report on "it_end" actions') 421 | }) 422 | desc('ANSI', () => { 423 | it('should escape codes') 424 | it('should return correct code') 425 | it('should return list of escaped codes') 426 | it('should format raw') 427 | it('should create a formatter function') 428 | it('should apply correct formatting') 429 | }) 430 | desc('curry()', () => { 431 | it('should return a function') 432 | it('should call function when last arg is passed') 433 | it('should call function when specified num of args is passed') 434 | it('should return same value as non-curried version') 435 | }) 436 | desc('compose()', () => { 437 | it('should return a function', () => { 438 | const v = compose(() => {}, () => {}) 439 | assert(typeof v === 'function') 440 | }, TypeError) 441 | it('should accept multiple args', () => { 442 | const v = compose(x => x * 2, (x, y, z) => x + y + z) 443 | assert(v(1, 2, 3) === 12) 444 | }) 445 | it('should return same value as non-composed version') 446 | }) 447 | }) 448 | } 449 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pico-test", 3 | "version": "0.0.1", 4 | "description": "⚡ PICO-8 testing framework", 5 | "main": "index", 6 | "engines": { 7 | "node": ">=4.0.0", 8 | "npm": ">=3.0.0" 9 | }, 10 | "scripts": { 11 | "test": "node index test" 12 | }, 13 | "preferGlobal": "true", 14 | "bin": { 15 | "pico-test" : "index" 16 | }, 17 | "author": "Josiah Savary (http://jsavary.com)", 18 | "license": "MIT", 19 | "homepage": "https://github.com/jozanza/pico-test", 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/jozanza/pico-test" 23 | }, 24 | "bugs": { 25 | "url": "https://github.com/jozanza/pico-test/issues" 26 | }, 27 | "keywords": [ 28 | "testing", 29 | "pico8", 30 | "pico-8" 31 | ] 32 | } 33 | --------------------------------------------------------------------------------