├── .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 |
6 |
7 |
8 |
9 |
10 |
11 |
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 |
--------------------------------------------------------------------------------