I was so sure my comments were clear when I wrote this a year ago. They were shit. So, most probably, is the current iteration. Feedback appreciated.
AIMA4HLDQ6GI74ESXMOJJ5UVHKZWPNED26G6SXX76G6ORQLYS5PAC CA5T33FG4HYQTI4LUHAVEPYCGLZETTGWJNS4PVGQ6JCPJBOLNQGQC ZD3ZKA5EPO6KQ4BWACSXMXS5VO7DCNPGE32S4W6EKWM7IXYFPHOAC S4IAYCIR5J4LPGBDAXI56E67IP3DAMSMKMDXYSRBGIEPQ2NE5ODQC NR43TWCNAUSV6MCAIGEGUTVCW32D37HCLQYPSVCJ6AVB7FOF255AC JMUD7T3OJTEF5V73Y2B2GSVUBNBSJFRJPZNREBE5E4BV3IG6SJLQC D2IADHMWPUAXHYUEWQZHFKBQGE527H7Z2W3IJTVBGZK2XEE5LVVQC DLBD4ZA6QZIT2SIZKIQQMCK3YR4T6PJZ2YVRLPKX3U5NPN7QCCLAC 6RYLD5ONDIQFWU5CNL4NGHJQ2LNAZZFGTPXQJDNJGLNYAUOTUI7QC 7IDHIAYI6QD7HU7VGOVO2FA7IG3NGD4TDH2Q2WPRDVXUR44OSFOQC 3QNOKBFMKBGXBVJIRHR2444JRRMBTABHE4674NR3DT67RRM2X6GAC AVTNUQYRBW7IX2YQ3KDLVQ23RGW3BAKTAE7P73ASBYNKOHMQMH5AC U7M4M2F7P5TGLTHKQ7J72GQFNPBII4PLJVJ44YVVOYEI4KPUDI6AC KKMFQDR43ZWVCDRHQLWWX3FCWCFA3ZSXYOBRJNPHUQZR2XPKWULAC 4KC7I3E2DIKLIP7LQRKB5WFA2Z5XZXAU46RFHNFQU5BVEJPDX6UQC 3QWK3GSA5KTVQJKXQ65OGZA2AORHFALLBXWP2A4MN6YDE7VV4PUAC JF5L2BBS7ESMKHNGKLXI2F32GZKET2ICJ4KT2L5BMH3P2L2Y5MRAC ZLJGZYQGQ2S4UFWTVF4PQDSGMP6A4IS4GDHCMBAAA5SK2N2NWR3QC 2CK5QI7WA7M4IVSACFGOJYAIDKRUTZVMMPSFWEJTUNMWTN7AX4NAC JFFUF5ALUWPDM7IEDEZVAYG2SVXO334STONRGKVB3QKY2TT5QGBQC 5UKUADTWMNWPOPBBTXUXY7UNFW64DWANI2RQHKSCSZNWHTQM4GUAC R53OF3ONKT5VL5BGK63YSN6GXIIAVNYDG4UMHITK72WXFWPJ25MQC YTSPVDZHEN5LLNMGIBUBLPWFWSFM3SOHBRGWYSDEVFKRTH24ARRQC AD34IX2ZSGYGU3LGY2IZOZNKD4HRQOYJVG5UWMWLXJZJSM62FFOAC 7CLGG7J277QZGMNOVFEXBX6DRETCVK7GH66HJ7BYOBMPHTJCDFMAC IMEJA43L3OX7S5KIYLZJ4F3ITACLAA5SZBHSCIJMULCPRSW7LXBAC FCFJVYKYPDR2YE7M2ZMGPLUVPBZMTFQEXHTQCDN2HSGYOMFQ5HPQC 4EGQRXDANFLUYXADP3MNHZWP2LBH2P5VBVKNN5RT6ERGMBVSRI2AC ORRSP7FVCHI2TF5GXBRGQYYJAA3JFYXZBM3T663BKSBV22FCZVCAC -- but we need to override it to install a test harness.---- A test harness needs to check what the 'real' code did.-- To do this it needs to hook into primitive operations performed by code.-- Our hooks all go through the `App` global. When running tests they operate-- on fake screen, keyboard and so on. Once all tests pass, the App global-- will hook into the real screen, keyboard and so on.---- Scroll below this function for more details.
-- but we need to override it to:-- * run all tests (functions starting with 'test_') on startup, and-- * save some state that makes it possible to switch between the main app-- and a source editor, while giving each the illusion of complete-- control.
-- I've been building LÖVE apps for a couple of months now, and often feel-- stupid. I seem to have a smaller short-term memory than most people, and-- LÖVE apps quickly grow to a point where I need longer and longer chunks of-- focused time to make changes to them. The reason: I don't have a way to-- write tests yet. So before I can change any piece of an app, I have to-- bring into my head all the ways it can break. This isn't the case on other-- platforms, where I can be productive in 5- or 10-minute increments. Because-- I have tests.---- Most test harnesses punt on testing I/O, and conventional wisdom is to test-- business logic, not I/O. However, any non-trivial app does non-trivial I/O-- that benefits from tests. And tests aren't very useful if it isn't obvious-- after reading them what the intent is. Including the I/O allows us to write-- tests that mimic how people use our program.---- There's a major open research problem in testing I/O: how to write tests-- for graphics. Pixel-by-pixel assertions get too verbose, and they're often-- brittle because you don't care about the precise state of every last pixel.-- Except when you do. Pixels are usually -- but not always -- the trees-- rather than the forest.---- I'm not in the business of doing research, so I'm going to shave off a-- small subset of the problem for myself here: how to write tests about text-- (ignoring font, color, etc.) on a graphic screen.---- For example, here's how you may write a test of a simple text paginator-- like `less`:-- function test_paginator()-- -- initialize environment-- App.filesystem['/tmp/foo'] = filename([[-- >abc-- >def-- >ghi-- >jkl-- ]])-- App.args = {'/tmp/foo'}-- -- define a screen with room for 2 lines of text-- App.screen.init{-- width=100-- height=30-- }-- App.font.init{-- height=15-- }-- -- check that screen shows next 2 lines of text after hitting pagedown-- App.run_after_keychord('pagedown')-- App.screen.check(0, 'ghi')-- App.screen.check(15, 'jkl')-- end---- All functions starting with 'test_' (no modules) will run before the app-- runs "for real". Each such test is a fake run of our entire program. It can-- set as much of the environment as it wants, then run the app. Here we've-- got a 30px screen and a 15px font, so the screen has room for 2 lines. The-- file we're viewing has 4 lines. We assert that hitting the 'pagedown' key-- shows the third and fourth lines.---- Programs can still perform graphics, and all graphics will work in the real-- program. We can't yet write tests for graphics, though. Those pixels are-- basically always blank in tests. Really, there isn't even any-- representation for them. All our fake screens know about is lines of text,-- and what (x,y) coordinates they start at. There's some rudimentary support-- for concatenating all blobs of text that start at the same 'y' coordinate,-- but beware: text at y=100 is separate and non-overlapping with text at-- y=101. You have to use the test harness within these limitations for your-- tests to faithfully model the real world.---- One drawback of this approach: the y coordinate used depends on font size,-- which feels brittle.
-- The rest of this file wraps around various LÖVE primitives to support-- automated tests. Often tests will run with a fake version of a primitive-- that redirects to the real love.* version once we're done with tests.
-- In the fullness of time App will support all side-effecting primitives-- exposed by LÖVE, but so far it supports just a rudimentary set of things I-- happen to have needed so far.
-- Not everything is so wrapped yet. Sometimes you still have to use love.*-- primitives directly.
function App.run_tests()local sorted_names = {}for name,binding in pairs(_G) doif name:find('test_') == 1 thentable.insert(sorted_names, name)endendtable.sort(sorted_names)for _,name in ipairs(sorted_names) doApp.initialize_for_test()--? print('=== '..name)--? _G[name]()xpcall(_G[name], function(err) prepend_debug_info_to_test_failure(name, err) end)end-- clean up all test methodsfor _,name in ipairs(sorted_names) do_G[name] = nilendend
-- App.screen.resize and App.screen.move seem like better names than-- love.window.setMode and love.window.setPosition respectively. They'll-- be side-effect-free during tests, and they'll save their results in-- attributes of App.screen for easy access.App.screen={}-- Use App.screen.init in tests to initialize the fake screen.
function App.color(color)love.graphics.setColor(color.r, color.g, color.b, color.a)
function App.screen.check(y, expected_contents, msg)--? print('checking for "'..expected_contents..'" at y '..tostring(y))local screen_row = 'y'..tostring(y)local contents = ''if App.screen.contents[screen_row] == nil thenerror('no text at y '..tostring(y))endfor i,s in ipairs(App.screen.contents[screen_row]) docontents = contents..sendcheck_eq(contents, expected_contents, msg)
function colortable(app_color)return {app_color.r, app_color.g, app_color.b, app_color.a}end
-- If you access the time using App.getTime instead of love.timer.getTime,-- tests will be able to move the time back and forwards as needed using-- App.wait_fake_time below.
-- In tests I mostly send chords all at once to the keyboard handlers.-- However, you'll occasionally need to check if a key is down outside a handler.-- If you use App.key_down instead of love.keyboard.isDown, tests will be able to-- simulate keypresses using App.fake_key_press and App.fake_key_release-- below. This isn't very realistic, though, and it's up to tests to-- orchestrate key presses that correspond to the handlers they invoke.
-- Tests mostly will invoke mouse handlers directly. However, you'll-- occasionally need to check if a mouse button is down outside a handler.-- If you use App.mouse_down instead of love.mouse.isDown, tests will be able to-- simulate mouse clicks using App.fake_mouse_press and App.fake_mouse_release-- below. This isn't very realistic, though, and it's up to tests to-- orchestrate presses that correspond to the handlers they invoke.
function App.fake_mouse_press(x,y, mouse_button)App.fake_mouse_state.x = xApp.fake_mouse_state.y = yApp.fake_mouse_state[mouse_button] = trueendfunction App.fake_mouse_release(x,y, mouse_button)App.fake_mouse_state.x = xApp.fake_mouse_state.y = yApp.fake_mouse_state[mouse_button] = nilend
endfunction App.fake_mouse_press(x,y, mouse_button)App.fake_mouse_state.x = xApp.fake_mouse_state.y = yApp.fake_mouse_state[mouse_button] = trueendfunction App.fake_mouse_release(x,y, mouse_button)App.fake_mouse_state.x = xApp.fake_mouse_state.y = yApp.fake_mouse_state[mouse_button] = nilend-- If you use App.open_for_reading and App.open_for_writing instead of other-- various Lua and LÖVE helpers, tests will be able to check the results of-- file operations inside the App.filesystem table.function App.open_for_writing(filename)App.filesystem[filename] = ''if Current_app == nil or Current_app == 'run' thenreturn {write = function(self, ...)local args = {...}for i,s in ipairs(args) doApp.filesystem[filename] = App.filesystem[filename]..sendend,close = function(self)end,}elseif Current_app == 'source' thenreturn {write = function(self, s)App.filesystem[filename] = App.filesystem[filename]..send,close = function(self)end,}endendfunction App.open_for_reading(filename)if App.filesystem[filename] thenreturn {lines = function(self)return App.filesystem[filename]:gmatch('[^\n]+')end,close = function(self)end,}end
function App.screen.check(y, expected_contents, msg)--? print('checking for "'..expected_contents..'" at y '..tostring(y))local screen_row = 'y'..tostring(y)local contents = ''if App.screen.contents[screen_row] == nil thenerror('no text at y '..tostring(y))endfor i,s in ipairs(App.screen.contents[screen_row]) docontents = contents..sendcheck_eq(contents, expected_contents, msg)end
-- miscellaneous internal helpers
-- fake filesfunction App.open_for_writing(filename)App.filesystem[filename] = ''if Current_app == nil or Current_app == 'run' thenreturn {write = function(self, ...)local args = {...}for i,s in ipairs(args) doApp.filesystem[filename] = App.filesystem[filename]..sendend,close = function(self)end,}elseif Current_app == 'source' thenreturn {write = function(self, s)App.filesystem[filename] = App.filesystem[filename]..send,close = function(self)end,}end
function App.color(color)love.graphics.setColor(color.r, color.g, color.b, color.a)
function App.open_for_reading(filename)if App.filesystem[filename] thenreturn {lines = function(self)return App.filesystem[filename]:gmatch('[^\n]+')end,close = function(self)end,}endendfunction App.run_tests()local sorted_names = {}for name,binding in pairs(_G) doif name:find('test_') == 1 thentable.insert(sorted_names, name)endendtable.sort(sorted_names)for _,name in ipairs(sorted_names) doApp.initialize_for_test()--? print('=== '..name)--? _G[name]()xpcall(_G[name], function(err) prepend_debug_info_to_test_failure(name, err) end)end-- clean up all test methodsfor _,name in ipairs(sorted_names) do_G[name] = nilend
function colortable(app_color)return {app_color.r, app_color.g, app_color.b, app_color.a}