basic test-enabled framework

Tests still have a lot of side-effects on the real screen. We'll
gradually clean those up.
This commit is contained in:
Kartik K. Agaram 2022-05-22 18:27:48 -07:00
parent 555726a87d
commit f421e1daa5
4 changed files with 251 additions and 34 deletions

189
app.lua
View File

@ -1,7 +1,21 @@
-- main entrypoint from LÖVE
-- main entrypoint for LÖVE
--
-- Most apps can just use the default, 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.
function love.run()
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
-- Tests always run at the start.
App.run_tests()
App.disable_tests()
if App.initialize then App.initialize(love.arg.parseGameArguments(arg), arg) end
if love.timer then love.timer.step() end
local dt = 0
@ -21,13 +35,13 @@ function love.run()
if love.timer then dt = love.timer.step() end
if love.update then love.update(dt) end -- will pass 0 if love.timer is disabled
if App.update then App.update(dt) end -- will pass 0 if love.timer is disabled
if love.graphics and love.graphics.isActive() then
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())
if love.draw then love.draw() end
if App.draw then App:draw() end
love.graphics.present()
end
@ -35,3 +49,168 @@ function love.run()
if love.timer then love.timer.sleep(0.001) end
end
end
-- 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'}
-- App.screen.init{
-- width=100
-- height=30
-- }
-- App.font{
-- height=15
-- }
-- App.run_with_keypress('pagedown')
-- App.check_screen_contents{
-- y0='ghi'
-- y15=''
-- }
-- 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.
--
-- 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.
App = {screen={}}
function App.initialize_for_test()
App.screen.init({width=100, height=50})
App.screen.contents = {} -- clear screen
end
function App.screen.init(dims)
App.screen.width = dims.width
App.screen.height = dims.height
end
function App.screen.print(msg, x,y)
local screen_row = 'y'..tostring(y)
local screen = App.screen
if screen.contents[screen_row] == nil then
screen.contents[screen_row] = {}
for i=0,screen.width-1 do
screen.contents[screen_row][i] = ''
end
end
if x < screen.width then
screen.contents[screen_row][x] = msg
end
end
-- LÖVE's Text primitive retains no trace of the string it was created from,
-- so we'll wrap it for our tests.
--
-- This implies that we need to hook any operations we need on Text objects.
function App.newText(font, s)
return {type='text', data=s, text=love.graphics.newText(font, s)}
end
function App.screen.draw(obj, x,y)
if type(obj) == 'userdata' then
-- ignore most things as graphics the test harness can't handle
elseif obj.type == 'text' then
App.screen.print(obj.data, x,y)
else
print(obj.type)
assert(false)
end
end
function App.run_after_textinput(t)
App.textinput(t)
App.screen.contents = {}
App.draw()
end
function App.width(text)
return text.text:getWidth()
end
function App.screen.check(y, expected_contents, msg)
local screen_row = 'y'..tostring(y)
local contents = ''
for i,s in ipairs(App.screen.contents[screen_row]) do
contents = contents..s
end
check_eq(contents, expected_contents, msg)
end
function App.run_tests()
for name,binding in pairs(_G) do
if name:find('test_') == 1 then
App.initialize_for_test()
binding()
end
end
print()
end
-- call this once all tests are run
-- can't run any tests after this
function App.disable_tests()
-- have LÖVE delegate all handlers to App if they exist
for name in pairs(love.handlers) do
if App[name] then
love.handlers[name] = App[name]
end
end
-- test methods are disallowed outside tests
App.screen.init = nil
App.run_after_textinput = nil
-- other methods dispatch to real hardware
App.screen.print = love.graphics.print
App.newText = love.graphics.newText
App.screen.draw = love.graphics.draw
App.width = function(text) return text:getWidth() end
end

View File

@ -1,14 +1,14 @@
-- Keyboard driver
function love.keypressed(key, scancode, isrepeat)
function App.keypressed(key, scancode, isrepeat)
if key == 'lctrl' or key == 'rctrl' or key == 'lalt' or key == 'ralt' or key == 'lshift' or key == 'rshift' or key == 'lgui' or key == 'rgui' then
-- do nothing when the modifier is pressed
end
-- include the modifier(s) when the non-modifer is pressed
keychord_pressed(combine_modifiers(key))
App.keychord_pressed(App.combine_modifiers(key))
end
function combine_modifiers(key)
function App.combine_modifiers(key)
local result = ''
local down = love.keyboard.isDown
if down('lctrl') or down('rctrl') then

View File

@ -1,6 +1,7 @@
local utf8 = require 'utf8'
require 'app'
require 'test'
require 'keychord'
require 'file'
@ -11,6 +12,12 @@ local geom = require 'geom'
require 'help'
require 'icons'
function App.initialize(arg)
love.keyboard.setTextInput(true) -- bring up keyboard on touch screen
love.keyboard.setKeyRepeat(true)
-- globals
-- a line is either text or a drawing
-- a text is a table with:
-- mode = 'text'
@ -54,14 +61,21 @@ Cursor1 = {line=1, pos=1} -- position of cursor
Screen_top1 = {line=1, pos=1} -- position of start of screen line at top of screen
Screen_bottom1 = {line=1, pos=1} -- position of start of screen line at bottom of screen
Screen_width, Screen_height, Screen_flags = 0, 0, nil
-- maximize window
love.window.setMode(0, 0) -- maximize
Screen_width, Screen_height, Screen_flags = love.window.getMode()
-- shrink slightly to account for window decoration
Screen_width = Screen_width-100
Screen_height = Screen_height-100
love.window.setMode(Screen_width, Screen_height)
Cursor_x, Cursor_y = 0, 0 -- in pixels
Current_drawing_mode = 'line'
Previous_drawing_mode = nil
Line_width = nil -- maximum width available to either text or drawings, in pixels
-- maximum width available to either text or drawings, in pixels
Line_width = math.floor(Screen_width/2/40)*40
Zoom = 1.5
@ -69,22 +83,6 @@ Filename = love.filesystem.getUserDirectory()..'/lines.txt'
New_foo = true
function love.load(arg)
-- maximize window
--? love.window.setMode(0, 0) -- maximize
--? Screen_width, Screen_height, Screen_flags = love.window.getMode()
--? -- shrink slightly to account for window decoration
--? Screen_width = Screen_width-100
--? Screen_height = Screen_height-100
-- for testing line wrap
Screen_width = 120
Screen_height = 200
love.window.setMode(Screen_width, Screen_height)
love.window.setTitle('Text with Lines')
Line_width = 100
--? Line_width = math.floor(Screen_width/2/40)*40
love.keyboard.setTextInput(true) -- bring up keyboard on touch screen
love.keyboard.setKeyRepeat(true)
if #arg > 0 then
Filename = arg[1]
end
@ -96,9 +94,10 @@ function love.load(arg)
end
end
love.window.setTitle('Text with Lines - '..Filename)
end
function love.filedropped(file)
function App.filedropped(file)
Filename = file:getFilename()
file:open('r')
Lines = load_from_file(file)
@ -112,7 +111,7 @@ function love.filedropped(file)
love.window.setTitle('Text with Lines - '..Filename)
end
function love.draw()
function App.draw()
Button_handlers = {}
love.graphics.setColor(1, 1, 1)
love.graphics.rectangle('fill', 0, 0, Screen_width-1, Screen_height-1)
@ -160,11 +159,11 @@ function love.draw()
--? os.exit(1)
end
function love.update(dt)
function App.update(dt)
Drawing.update(dt)
end
function love.mousepressed(x,y, mouse_button)
function App.mousepressed(x,y, mouse_button)
propagate_to_button_handlers(x,y, mouse_button)
for line_index,line in ipairs(Lines) do
@ -180,11 +179,11 @@ function love.mousepressed(x,y, mouse_button)
end
end
function love.mousereleased(x,y, button)
function App.mousereleased(x,y, button)
Drawing.mouse_released(x,y, button)
end
function love.textinput(t)
function App.textinput(t)
if Current_drawing_mode == 'name' then
local drawing = Lines.current
local p = drawing.points[drawing.pending.target_point]
@ -195,7 +194,7 @@ function love.textinput(t)
save_to_disk(Lines, Filename)
end
function keychord_pressed(chord)
function App.keychord_pressed(chord)
New_foo = true
if love.mouse.isDown('1') or chord:sub(1,2) == 'C-' then
Drawing.keychord_pressed(chord)
@ -253,5 +252,5 @@ function keychord_pressed(chord)
end
end
function love.keyreleased(key, scancode)
function App.keyreleased(key, scancode)
end

39
test.lua Normal file
View File

@ -0,0 +1,39 @@
-- Some primitives for tests.
--
-- Success indicators go to the terminal; failures go to the window.
-- I don't know what I am doing.
function check(x, msg)
if x then
io.write('.')
else
error(msg)
end
end
function check_eq(x, expected, msg)
if eq(x, expected) then
io.write('.')
else
error(msg..'; got "'..x..'"')
end
end
function eq(a, b)
if type(a) ~= type(b) then return false end
if type(a) == 'table' then
if #a ~= #b then return false end
for k, v in pairs(a) do
if b[k] ~= v then
return false
end
end
for k, v in pairs(b) do
if a[k] ~= v then
return false
end
end
return true
end
return a == b
end