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) do
if name:find('test_') == 1 then
table.insert(sorted_names, name)
end
end
table.sort(sorted_names)
for _,name in ipairs(sorted_names) do
App.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 methods
for _,name in ipairs(sorted_names) do
_G[name] = nil
end
end
-- 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 then
error('no text at y '..tostring(y))
end
for i,s in ipairs(App.screen.contents[screen_row]) do
contents = contents..s
end
check_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 = x
App.fake_mouse_state.y = y
App.fake_mouse_state[mouse_button] = true
end
function App.fake_mouse_release(x,y, mouse_button)
App.fake_mouse_state.x = x
App.fake_mouse_state.y = y
App.fake_mouse_state[mouse_button] = nil
end
end
function App.fake_mouse_press(x,y, mouse_button)
App.fake_mouse_state.x = x
App.fake_mouse_state.y = y
App.fake_mouse_state[mouse_button] = true
end
function App.fake_mouse_release(x,y, mouse_button)
App.fake_mouse_state.x = x
App.fake_mouse_state.y = y
App.fake_mouse_state[mouse_button] = nil
end
-- 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' then
return {
write = function(self, ...)
local args = {...}
for i,s in ipairs(args) do
App.filesystem[filename] = App.filesystem[filename]..s
end
end,
close = function(self)
end,
}
elseif Current_app == 'source' then
return {
write = function(self, s)
App.filesystem[filename] = App.filesystem[filename]..s
end,
close = function(self)
end,
}
end
end
function App.open_for_reading(filename)
if App.filesystem[filename] then
return {
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 then
error('no text at y '..tostring(y))
end
for i,s in ipairs(App.screen.contents[screen_row]) do
contents = contents..s
end
check_eq(contents, expected_contents, msg)
end
-- miscellaneous internal helpers
-- fake files
function App.open_for_writing(filename)
App.filesystem[filename] = ''
if Current_app == nil or Current_app == 'run' then
return {
write = function(self, ...)
local args = {...}
for i,s in ipairs(args) do
App.filesystem[filename] = App.filesystem[filename]..s
end
end,
close = function(self)
end,
}
elseif Current_app == 'source' then
return {
write = function(self, s)
App.filesystem[filename] = App.filesystem[filename]..s
end,
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] then
return {
lines = function(self)
return App.filesystem[filename]:gmatch('[^\n]+')
end,
close = function(self)
end,
}
end
end
function App.run_tests()
local sorted_names = {}
for name,binding in pairs(_G) do
if name:find('test_') == 1 then
table.insert(sorted_names, name)
end
end
table.sort(sorted_names)
for _,name in ipairs(sorted_names) do
App.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 methods
for _,name in ipairs(sorted_names) do
_G[name] = nil
end
function colortable(app_color)
return {app_color.r, app_color.g, app_color.b, app_color.a}