diff --git a/app.lua b/app.lua index a2124d0..9d202e4 100644 --- a/app.lua +++ b/app.lua @@ -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 diff --git a/keychord.lua b/keychord.lua index d9b89f5..12feef7 100644 --- a/keychord.lua +++ b/keychord.lua @@ -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 diff --git a/main.lua b/main.lua index 168eabb..bdb16f2 100644 --- a/main.lua +++ b/main.lua @@ -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 diff --git a/test.lua b/test.lua new file mode 100644 index 0000000..3493de4 --- /dev/null +++ b/test.lua @@ -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