about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorKartik K. Agaram <vc@akkartik.com>2022-05-22 18:27:48 -0700
committerKartik K. Agaram <vc@akkartik.com>2022-05-22 18:29:52 -0700
commitf421e1daa52a52956f40b3ee6d8b527ba1c30d5a (patch)
treee1a6a9c166cb53d0a9f61942193e93e3d40ad7c6
parent555726a87daf815d71e73c89749f56d0ac525717 (diff)
downloadtext.love-f421e1daa52a52956f40b3ee6d8b527ba1c30d5a.tar.gz
basic test-enabled framework
Tests still have a lot of side-effects on the real screen. We'll
gradually clean those up.
-rw-r--r--app.lua189
-rw-r--r--keychord.lua6
-rw-r--r--main.lua51
-rw-r--r--test.lua39
4 files changed, 251 insertions, 34 deletions
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