1 -- love.run: main entrypoint function for LÖVE
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.
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.
15 -- Tests always run at the start.
16 App
.run_tests_and_initialize()
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
31 love
.handlers
[name
](a
,b
,c
,d
,e
,f
)
35 dt
= love
.timer
.step()
38 love
.graphics
.origin()
39 love
.graphics
.clear(love
.graphics
.getBackgroundColor())
41 love
.graphics
.present()
43 love
.timer
.sleep(0.001)
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
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
74 -- function test_paginator()
75 -- -- initialize environment
76 -- App.filesystem['/tmp/foo'] = filename([[
82 -- App.args = {'/tmp/foo'}
83 -- -- define a screen with room for 2 lines of text
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')
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.
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
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()
139 if #Test_errors
> 0 then
140 error(('There were %d test failures:\n\n%s'):format(#Test_errors
, table.concat(Test_errors
)))
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
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
)
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
}
206 function App
.getTime()
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
)
218 function App
.getClipboardText()
221 function App
.setClipboardText(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
)
267 App
.screen
.contents
= {}
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
= {}
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
= {}
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
= {}
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
= {}
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
)
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
)
317 function App
.open_for_writing(filename
)
318 App
.filesystem
[filename
] = ''
319 if Current_app
== nil or Current_app
== 'run' then
321 write = function(self
, ...)
323 for i
,s
in ipairs(args
) do
324 App
.filesystem
[filename
] = App
.filesystem
[filename
]..s
327 close
= function(self
)
330 elseif Current_app
== 'source' then
332 write = function(self
, s
)
333 App
.filesystem
[filename
] = App
.filesystem
[filename
]..s
335 close
= function(self
)
341 function App
.open_for_reading(filename
)
342 if App
.filesystem
[filename
] then
344 lines
= function(self
)
345 return App
.filesystem
[filename
]:gmatch('[^\n]+')
347 close
= function(self
)
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)
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
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
389 love
.handlers
[name
] = App
[name
]
393 -- test methods are disallowed outside tests
395 App
.disable_tests
= nil
396 App
.screen
.init
= nil
399 App
.run_after_textinput
= nil
400 App
.run_after_keychord
= 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
=
425 local result
= love
.filesystem
.newFile(filename
)
426 local ok
, err
= result
:open('r')
433 App
.open_for_writing
=
435 local result
= love
.filesystem
.newFile(filename
)
436 local ok
, err
= result
:open('w')
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