tweak wording in test list
[view.love.git] / app.lua
blob89c7bf12c30b25136e95e112e7de999e186f98fa
1 -- love.run: main entrypoint function for LÖVE
2 --
3 -- Most apps can just use the default shown in https://love2d.org/wiki/love.run,
4 -- but we need to override it to install a test harness.
5 --
6 -- A test harness needs to check what the 'real' code did.
7 -- To do this it needs to hook into primitive operations performed by code.
8 -- Our hooks all go through the `App` global. When running tests they operate
9 -- on fake screen, keyboard and so on. Once all tests pass, the App global
10 -- will hook into the real screen, keyboard and so on.
12 -- Scroll below this function for more details.
13 function love.run()
14 App.snapshot_love()
15 -- Tests always run at the start.
16 App.run_tests_and_initialize()
17 --? print('==')
19 love.timer.step()
20 local dt = 0
22 return function()
23 if love.event then
24 love.event.pump()
25 for name, a,b,c,d,e,f in love.event.poll() do
26 if name == "quit" then
27 if not love.quit or not love.quit() then
28 return a or 0
29 end
30 end
31 love.handlers[name](a,b,c,d,e,f)
32 end
33 end
35 dt = love.timer.step()
36 App.update(dt)
38 love.graphics.origin()
39 love.graphics.clear(love.graphics.getBackgroundColor())
40 App.draw()
41 love.graphics.present()
43 love.timer.sleep(0.001)
44 end
45 end
47 -- I've been building LÖVE apps for a couple of months now, and often feel
48 -- stupid. I seem to have a smaller short-term memory than most people, and
49 -- LÖVE apps quickly grow to a point where I need longer and longer chunks of
50 -- focused time to make changes to them. The reason: I don't have a way to
51 -- write tests yet. So before I can change any piece of an app, I have to
52 -- bring into my head all the ways it can break. This isn't the case on other
53 -- platforms, where I can be productive in 5- or 10-minute increments. Because
54 -- I have tests.
56 -- Most test harnesses punt on testing I/O, and conventional wisdom is to test
57 -- business logic, not I/O. However, any non-trivial app does non-trivial I/O
58 -- that benefits from tests. And tests aren't very useful if it isn't obvious
59 -- after reading them what the intent is. Including the I/O allows us to write
60 -- tests that mimic how people use our program.
62 -- There's a major open research problem in testing I/O: how to write tests
63 -- for graphics. Pixel-by-pixel assertions get too verbose, and they're often
64 -- brittle because you don't care about the precise state of every last pixel.
65 -- Except when you do. Pixels are usually -- but not always -- the trees
66 -- rather than the forest.
68 -- I'm not in the business of doing research, so I'm going to shave off a
69 -- small subset of the problem for myself here: how to write tests about text
70 -- (ignoring font, color, etc.) on a graphic screen.
72 -- For example, here's how you may write a test of a simple text paginator
73 -- like `less`:
74 -- function test_paginator()
75 -- -- initialize environment
76 -- App.filesystem['/tmp/foo'] = filename([[
77 -- >abc
78 -- >def
79 -- >ghi
80 -- >jkl
81 -- ]])
82 -- App.args = {'/tmp/foo'}
83 -- -- define a screen with room for 2 lines of text
84 -- App.screen.init{
85 -- width=100
86 -- height=30
87 -- }
88 -- App.font.init{
89 -- height=15
90 -- }
91 -- -- check that screen shows next 2 lines of text after hitting pagedown
92 -- App.run_after_keychord('pagedown')
93 -- App.screen.check(0, 'ghi')
94 -- App.screen.check(15, 'jkl')
95 -- end
97 -- All functions starting with 'test_' (no modules) will run before the app
98 -- runs "for real". Each such test is a fake run of our entire program. It can
99 -- set as much of the environment as it wants, then run the app. Here we've
100 -- got a 30px screen and a 15px font, so the screen has room for 2 lines. The
101 -- file we're viewing has 4 lines. We assert that hitting the 'pagedown' key
102 -- shows the third and fourth lines.
104 -- Programs can still perform graphics, and all graphics will work in the real
105 -- program. We can't yet write tests for graphics, though. Those pixels are
106 -- basically always blank in tests. Really, there isn't even any
107 -- representation for them. All our fake screens know about is lines of text,
108 -- and what (x,y) coordinates they start at. There's some rudimentary support
109 -- for concatenating all blobs of text that start at the same 'y' coordinate,
110 -- but beware: text at y=100 is separate and non-overlapping with text at
111 -- y=101. You have to use the test harness within these limitations for your
112 -- tests to faithfully model the real world.
114 -- One drawback of this approach: the y coordinate used depends on font size,
115 -- which feels brittle.
117 -- In the fullness of time App will support all side-effecting primitives
118 -- exposed by LÖVE, but so far it supports just a rudimentary set of things I
119 -- happen to have needed so far.
121 App = {screen={}}
123 -- save/restore various framework globals we care about -- only on very first load
124 function App.snapshot_love()
125 if Love_snapshot then return end
126 Love_snapshot = {}
127 -- save the entire initial font; it doesn't seem reliably recreated using newFont
128 Love_snapshot.initial_font = love.graphics.getFont()
131 function App.undo_initialize()
132 love.graphics.setFont(Love_snapshot.initial_font)
135 function App.run_tests_and_initialize()
136 App.load()
137 Test_errors = {}
138 App.run_tests()
139 if #Test_errors > 0 then
140 error(('There were %d test failures:\n\n%s'):format(#Test_errors, table.concat(Test_errors)))
142 App.disable_tests()
143 App.initialize_globals()
144 App.initialize(love.arg.parseGameArguments(arg), arg)
147 function App.initialize_for_test()
148 App.screen.init{width=100, height=50}
149 App.screen.contents = {} -- clear screen
150 App.filesystem = {}
151 App.fake_keys_pressed = {}
152 App.fake_mouse_state = {x=-1, y=-1}
153 App.initialize_globals()
156 function App.screen.init(dims)
157 App.screen.width = dims.width
158 App.screen.height = dims.height
161 -- operations on the LÖVE window within the monitor/display
162 function App.screen.resize(width, height, flags)
163 App.screen.width = width
164 App.screen.height = height
165 App.screen.flags = flags
168 function App.screen.size()
169 return App.screen.width, App.screen.height, App.screen.flags
172 function App.screen.move(x,y, displayindex)
173 App.screen.x = x
174 App.screen.y = y
175 App.screen.displayindex = displayindex
178 function App.screen.position()
179 return App.screen.x, App.screen.y, App.screen.displayindex
182 function App.screen.print(msg, x,y)
183 local screen_row = 'y'..tostring(y)
184 --? print('drawing "'..msg..'" at y '..tostring(y))
185 local screen = App.screen
186 if screen.contents[screen_row] == nil then
187 screen.contents[screen_row] = {}
188 for i=0,screen.width-1 do
189 screen.contents[screen_row][i] = ''
192 if x < screen.width then
193 screen.contents[screen_row][x] = msg
197 function App.color(color)
198 love.graphics.setColor(color.r, color.g, color.b, color.a)
201 function colortable(app_color)
202 return {app_color.r, app_color.g, app_color.b, app_color.a}
205 App.time = 1
206 function App.getTime()
207 return App.time
209 function App.wait_fake_time(t)
210 App.time = App.time + t
213 function App.width(text)
214 return love.graphics.getFont():getWidth(text)
217 App.clipboard = ''
218 function App.getClipboardText()
219 return App.clipboard
221 function App.setClipboardText(s)
222 App.clipboard = s
225 App.fake_keys_pressed = {}
226 function App.fake_key_press(key)
227 App.fake_keys_pressed[key] = true
229 function App.fake_key_release(key)
230 App.fake_keys_pressed[key] = nil
232 function App.key_down(key)
233 return App.fake_keys_pressed[key]
236 App.fake_mouse_state = {x=-1, y=-1} -- x,y always set
237 function App.fake_mouse_press(x,y, mouse_button)
238 App.fake_mouse_state.x = x
239 App.fake_mouse_state.y = y
240 App.fake_mouse_state[mouse_button] = true
242 function App.fake_mouse_release(x,y, mouse_button)
243 App.fake_mouse_state.x = x
244 App.fake_mouse_state.y = y
245 App.fake_mouse_state[mouse_button] = nil
247 function App.mouse_move(x,y)
248 App.fake_mouse_state.x = x
249 App.fake_mouse_state.y = y
251 function App.mouse_down(mouse_button)
252 return App.fake_mouse_state[mouse_button]
254 function App.mouse_x()
255 return App.fake_mouse_state.x
257 function App.mouse_y()
258 return App.fake_mouse_state.y
261 -- all textinput events are also keypresses
262 -- TODO: handle chords of multiple keys
263 function App.run_after_textinput(t)
264 App.keypressed(t)
265 App.textinput(t)
266 App.keyreleased(t)
267 App.screen.contents = {}
268 App.draw()
271 -- not all keys are textinput
272 -- TODO: handle chords of multiple keys
273 function App.run_after_keychord(chord)
274 App.keychord_press(chord)
275 App.keyreleased(chord)
276 App.screen.contents = {}
277 App.draw()
280 function App.run_after_mouse_click(x,y, mouse_button)
281 App.fake_mouse_press(x,y, mouse_button)
282 App.mousepressed(x,y, mouse_button)
283 App.fake_mouse_release(x,y, mouse_button)
284 App.mousereleased(x,y, mouse_button)
285 App.screen.contents = {}
286 App.draw()
289 function App.run_after_mouse_press(x,y, mouse_button)
290 App.fake_mouse_press(x,y, mouse_button)
291 App.mousepressed(x,y, mouse_button)
292 App.screen.contents = {}
293 App.draw()
296 function App.run_after_mouse_release(x,y, mouse_button)
297 App.fake_mouse_release(x,y, mouse_button)
298 App.mousereleased(x,y, mouse_button)
299 App.screen.contents = {}
300 App.draw()
303 function App.screen.check(y, expected_contents, msg)
304 --? print('checking for "'..expected_contents..'" at y '..tostring(y))
305 local screen_row = 'y'..tostring(y)
306 local contents = ''
307 if App.screen.contents[screen_row] == nil then
308 error('no text at y '..tostring(y))
310 for i,s in ipairs(App.screen.contents[screen_row]) do
311 contents = contents..s
313 check_eq(contents, expected_contents, msg)
316 -- fake files
317 function App.open_for_writing(filename)
318 App.filesystem[filename] = ''
319 if Current_app == nil or Current_app == 'run' then
320 return {
321 write = function(self, ...)
322 local args = {...}
323 for i,s in ipairs(args) do
324 App.filesystem[filename] = App.filesystem[filename]..s
326 end,
327 close = function(self)
328 end,
330 elseif Current_app == 'source' then
331 return {
332 write = function(self, s)
333 App.filesystem[filename] = App.filesystem[filename]..s
334 end,
335 close = function(self)
336 end,
341 function App.open_for_reading(filename)
342 if App.filesystem[filename] then
343 return {
344 lines = function(self)
345 return App.filesystem[filename]:gmatch('[^\n]+')
346 end,
347 close = function(self)
348 end,
353 function App.run_tests()
354 local sorted_names = {}
355 for name,binding in pairs(_G) do
356 if name:find('test_') == 1 then
357 table.insert(sorted_names, name)
360 table.sort(sorted_names)
361 for _,name in ipairs(sorted_names) do
362 App.initialize_for_test()
363 --? print('=== '..name)
364 --? _G[name]()
365 xpcall(_G[name], function(err) prepend_debug_info_to_test_failure(name, err) end)
367 -- clean up all test methods
368 for _,name in ipairs(sorted_names) do
369 _G[name] = nil
373 -- prepend file/line/test
374 function prepend_debug_info_to_test_failure(test_name, err)
375 local err_without_line_number = err:gsub('^[^:]*:[^:]*: ', '')
376 local stack_trace = debug.traceback('', --[[stack frame]]5)
377 local file_and_line_number = stack_trace:gsub('stack traceback:\n', ''):gsub(': .*', '')
378 local full_error = file_and_line_number..':'..test_name..' -- '..err_without_line_number
379 --? local full_error = file_and_line_number..':'..test_name..' -- '..err_without_line_number..'\t\t'..stack_trace:gsub('\n', '\n\t\t')
380 table.insert(Test_errors, full_error)
383 -- call this once all tests are run
384 -- can't run any tests after this
385 function App.disable_tests()
386 -- have LÖVE delegate all handlers to App if they exist
387 for name in pairs(love.handlers) do
388 if App[name] then
389 love.handlers[name] = App[name]
393 -- test methods are disallowed outside tests
394 App.run_tests = nil
395 App.disable_tests = nil
396 App.screen.init = nil
397 App.filesystem = nil
398 App.time = nil
399 App.run_after_textinput = nil
400 App.run_after_keychord = nil
401 App.keypress = nil
402 App.keyrelease = nil
403 App.run_after_mouse_click = nil
404 App.run_after_mouse_press = nil
405 App.run_after_mouse_release = nil
406 App.fake_keys_pressed = nil
407 App.fake_key_press = nil
408 App.fake_key_release = nil
409 App.fake_mouse_state = nil
410 App.fake_mouse_press = nil
411 App.fake_mouse_release = nil
412 -- other methods dispatch to real hardware
413 App.screen.resize = love.window.setMode
414 App.screen.size = love.window.getMode
415 App.screen.move = love.window.setPosition
416 App.screen.position = love.window.getPosition
417 App.screen.print = love.graphics.print
418 if Current_app == nil or Current_app == 'run' then
419 App.open_for_reading = function(filename) return io.open(filename, 'r') end
420 App.open_for_writing = function(filename) return io.open(filename, 'w') end
421 elseif Current_app == 'source' then
422 -- HACK: source editor requires a couple of different foundational definitions
423 App.open_for_reading =
424 function(filename)
425 local result = love.filesystem.newFile(filename)
426 local ok, err = result:open('r')
427 if ok then
428 return result
429 else
430 return ok, err
433 App.open_for_writing =
434 function(filename)
435 local result = love.filesystem.newFile(filename)
436 local ok, err = result:open('w')
437 if ok then
438 return result
439 else
440 return ok, err
444 App.getTime = love.timer.getTime
445 App.getClipboardText = love.system.getClipboardText
446 App.setClipboardText = love.system.setClipboardText
447 App.key_down = love.keyboard.isDown
448 App.mouse_move = love.mouse.setPosition
449 App.mouse_down = love.mouse.isDown
450 App.mouse_x = love.mouse.getX
451 App.mouse_y = love.mouse.getY