diff --git a/spec/00-helpers_spec.lua b/spec/00-helpers_spec.lua new file mode 100644 index 0000000..1672be6 --- /dev/null +++ b/spec/00-helpers_spec.lua @@ -0,0 +1,201 @@ +describe("Spec helpers", function() + + local helpers + + setup(function() + helpers = require("spec.helpers") + end) + + + + describe("(un)loading", function() + + it("removes terminal and system from package.loaded", function() + helpers.load() + assert.is_not_nil(package.loaded["terminal"]) + helpers.unload() + assert.is_nil(package.loaded["terminal"]) + assert.is_nil(package.loaded["system"]) + end) + + + it("reloads terminal after unload", function() + helpers.unload() + local _ = helpers.load() + assert.is_not_nil(package.loaded["terminal"]) + helpers.unload() + assert.is_nil(package.loaded["terminal"]) + local t2 = helpers.load() + assert.is_table(t2) + assert.is_not_nil(package.loaded["terminal"]) + end) + + + it("returns terminal module with expected API", function() + helpers.unload() + local terminal = helpers.load() + assert.is_table(terminal.input) + end) + + + it("returns a fresh terminal table on reload", function() + helpers.unload() + local t1 = helpers.load() + helpers.unload() + local t2 = helpers.load() + assert.is_table(t2) + assert.is_table(t2.utils) + assert.is_table(t2.input) + assert.not_equal(t1, t2) + end) + + end) + + + + describe("termsize", function() + + it("defaults to 25x80", function() + helpers.load() + + local rows, cols = helpers.get_termsize() + assert.equals(25, rows) + assert.equals(80, cols) + end) + + + it("patches system.termsize to use mocked values", function() + helpers.load() + + helpers.set_termsize(30, 90) + + local system = require("system") + local rows, cols = system.termsize() + assert.equals(30, rows) + assert.equals(90, cols) + end) + + end) + + + + describe("keyboard input mock", function() + + it("returns bytes from the helper _readkey buffer", function() + helpers.load() + + helpers._push_input("ab") + + local b1 = helpers._readkey() + local b2 = helpers._readkey() + local b3 = helpers._readkey() + + assert.equals(string.byte("a"), b1) + assert.equals(string.byte("b"), b2) + assert.is_nil(b3) + end) + + + it("returns nil and error when pushing an error entry", function() + helpers.load() + + helpers._push_input(nil, "some-error") + + local b, err = helpers._readkey() + assert.is_nil(b) + assert.equals("some-error", err) + end) + + + it("patches system._readkey to use the mock buffer", function() + helpers.load() + + helpers._push_input("X") + + local system = require("system") + local b = system._readkey() + + assert.equals(string.byte("X"), b) + end) + + + it("terminal.input.readansi() returns data read from the mock buffer", function() + local terminal = helpers.load() + + helpers._push_input("X") + + local rawkey, keytype = terminal.input.readansi(0.01) + assert.equals("X", rawkey) + assert.equals("char", keytype) + end) + + end) + + + + describe("output capture", function() + + it("accumulates writes between reads", function() + local terminal = helpers.load() + + terminal.output.write("one") + local first = helpers.get_output() + + terminal.output.write("two") + local second = helpers.get_output() + + assert.equals("one", first) + assert.equals("onetwo", second) + end) + + + it("clears output and starts fresh", function() + local terminal = helpers.load() + + terminal.output.write("abc") + assert.equals("abc", helpers.get_output()) + + helpers.clear_output() + terminal.output.write("xyz") + + local out = helpers.get_output() + assert.equals("xyz", out) + end) + + end) + + + + describe("keys lookup", function() + + it("is read-only", function() + helpers.load() + + assert.has_error(function() + helpers.keys.enter = "something" + end, "table is read-only") + end) + + + it("returns a raw sequence that maps back to the same keyname", function() + local terminal = helpers.load() + + local raw = helpers.keys.enter + local keyname_from_map = terminal.input.keymap.default_key_map[raw] + local keyname_from_keys = terminal.input.keymap.default_keys.enter + + assert.equals(keyname_from_keys, keyname_from_map) + end) + + + it("errors on unknown key name", function() + helpers.load() + + assert.has_error(function() + local _ = helpers.keys.this_key_does_not_exist + end, "Unknown key-name: this_key_does_not_exist") + end) + + end) + +end) diff --git a/spec/05-scroll_stack_spec.lua b/spec/05-scroll_stack_spec.lua index 917a336..95502b1 100644 --- a/spec/05-scroll_stack_spec.lua +++ b/spec/05-scroll_stack_spec.lua @@ -1,33 +1,19 @@ -describe("Scroll stack", function() +local helpers = require "spec.helpers" - local stack, scroll, old_sys_termsize - before_each(function() - _G._TEST = true +describe("Scroll stack", function() - local sys = require "system" - old_sys_termsize = sys.termsize - if os.getenv("GITHUB_ACTIONS") then - sys.termsize = function() - return 25, 80 - end - end + local terminal, stack, scroll - stack = require "terminal.scroll.stack" - scroll = require "terminal.scroll" + before_each(function() + terminal = helpers.load() + stack = terminal.scroll.stack + scroll = terminal.scroll end) after_each(function() - _G._TEST = nil - - require("system").termsize = old_sys_termsize - - for mod in pairs(package.loaded) do - if mod:match("^terminal") then - package.loaded[mod] = nil - end - end + helpers.unload() end) diff --git a/spec/06-cursor_spec.lua b/spec/06-cursor_spec.lua index a72498d..24fdf12 100644 --- a/spec/06-cursor_spec.lua +++ b/spec/06-cursor_spec.lua @@ -1,33 +1,22 @@ -describe("Cursor", function() +local helpers = require "spec.helpers" - local cursor, old_sys_termsize - before_each(function() - for mod in pairs(package.loaded) do - if mod:match("^terminal") then - package.loaded[mod] = nil - end - end +describe("Cursor", function() - local sys = require "system" - old_sys_termsize = sys.termsize - if os.getenv("GITHUB_ACTIONS") then - sys.termsize = function() - return 25, 80 - end - end + local terminal, cursor - cursor = require "terminal.cursor" + before_each(function() + terminal = helpers.load() + cursor = terminal.cursor end) after_each(function() - require("system").termsize = old_sys_termsize + helpers.unload() end) - describe("visible.set()", function() it("returns ANSI sequence for hiding the cursor", function() diff --git a/spec/11-screen_spec.lua b/spec/11-screen_spec.lua index 8be9316..085a3a5 100644 --- a/spec/11-screen_spec.lua +++ b/spec/11-screen_spec.lua @@ -1,7 +1,5 @@ -#!/usr/bin/env lua +local helpers = require "spec.helpers" ---- Tests for terminal.ui.screen module --- @module spec.11-screen_spec describe("terminal.ui.screen", function() @@ -10,15 +8,14 @@ describe("terminal.ui.screen", function() local terminal before_each(function() + terminal = helpers.load() Screen = require("terminal.ui.panel.screen") Panel = require("terminal.ui.panel") - terminal = require("terminal") end) + after_each(function() - Screen = nil - Panel = nil - terminal = nil + helpers.unload() end) diff --git a/spec/13-bar_spec.lua b/spec/13-bar_spec.lua index b11a3cb..9076b90 100644 --- a/spec/13-bar_spec.lua +++ b/spec/13-bar_spec.lua @@ -1,3 +1,6 @@ +local helpers = require "spec.helpers" + + describe("terminal.ui.panel.bar", function() local Bar @@ -5,8 +8,8 @@ describe("terminal.ui.panel.bar", function() setup(function() -- Load modules + terminal = helpers.load() Bar = require("terminal.ui.panel.bar") - terminal = require("terminal") -- Mock terminal functions for testing terminal.cursor = { @@ -29,6 +32,7 @@ describe("terminal.ui.panel.bar", function() -- Unset modules for clean test isolation Bar = nil terminal = nil -- luacheck: ignore + helpers.unload() end) diff --git a/spec/14-text_panel_spec.lua b/spec/14-text_panel_spec.lua index 0532e06..6488a40 100644 --- a/spec/14-text_panel_spec.lua +++ b/spec/14-text_panel_spec.lua @@ -1,3 +1,6 @@ +local helpers = require "spec.helpers" + + describe("terminal.ui.panel.text", function() local TextPanel @@ -5,8 +8,8 @@ describe("terminal.ui.panel.text", function() local text setup(function() + terminal = helpers.load() TextPanel = require("terminal.ui.panel.text") - terminal = require("terminal") text = require("terminal.text") -- Mock terminal functions @@ -39,6 +42,7 @@ describe("terminal.ui.panel.text", function() TextPanel = nil terminal = nil -- luacheck: ignore text = nil + helpers.unload() end) diff --git a/spec/15-key_bar_spec.lua b/spec/15-key_bar_spec.lua index 73576a9..67e8aa6 100644 --- a/spec/15-key_bar_spec.lua +++ b/spec/15-key_bar_spec.lua @@ -1,3 +1,6 @@ +local helpers = require "spec.helpers" + + describe("terminal.ui.panel.key_bar", function() local KeyBar @@ -6,8 +9,8 @@ describe("terminal.ui.panel.key_bar", function() setup(function() -- Load modules + terminal = helpers.load() KeyBar = require("terminal.ui.panel.key_bar") - terminal = require("terminal") text = require("terminal.text") -- Mock terminal functions for testing @@ -30,6 +33,7 @@ describe("terminal.ui.panel.key_bar", function() -- Unset modules for clean test isolation KeyBar = nil terminal = nil -- luacheck: ignore + helpers.unload() end) diff --git a/spec/17-tab_strip_spec.lua b/spec/17-tab_strip_spec.lua index 6b049f7..de724eb 100644 --- a/spec/17-tab_strip_spec.lua +++ b/spec/17-tab_strip_spec.lua @@ -1,3 +1,6 @@ +local helpers = require "spec.helpers" + + describe("terminal.ui.panel.tab_strip", function() local TabStrip @@ -5,8 +8,8 @@ describe("terminal.ui.panel.tab_strip", function() setup(function() -- Load modules + terminal = helpers.load() TabStrip = require("terminal.ui.panel.tab_strip") - terminal = require("terminal") -- Mock terminal functions for testing terminal.cursor = { position = { @@ -36,6 +39,7 @@ describe("terminal.ui.panel.tab_strip", function() -- Unset modules for clean test isolation TabStrip = nil terminal = nil -- luacheck: ignore + helpers.unload() end) diff --git a/spec/18-prompt_spec.lua b/spec/18-prompt_spec.lua index fd37e96..b330938 100644 --- a/spec/18-prompt_spec.lua +++ b/spec/18-prompt_spec.lua @@ -1,87 +1,18 @@ ---- Tests for terminal.cli.prompt edge cases --- Covers: empty value, cancelled/returned behavior +local helpers = require "spec.helpers" + describe("terminal.cli.prompt", function() local Prompt - local t - local old_size, old_write, old_print, old_readansi - local input_queue - local ENTER_KEY, ESC_KEY, CTRL_C_KEY - local queue_key - - setup(function() - local keymap_module = require("terminal.input.keymap") - local keys = keymap_module.default_keys - local default_key_map = keymap_module.default_key_map - - for raw_key, key_name in pairs(default_key_map) do - if key_name == keys.enter then - ENTER_KEY = raw_key - break - end - end - assert(ENTER_KEY, "Could not find Enter key in keymap") - - for raw_key, key_name in pairs(default_key_map) do - if key_name == keys.escape then - ESC_KEY = raw_key - break - end - end - assert(ESC_KEY, "Could not find Escape key in keymap") - - for raw_key, key_name in pairs(default_key_map) do - if key_name == keys.ctrl_c then - CTRL_C_KEY = raw_key - break - end - end - assert(CTRL_C_KEY, "Could not find Ctrl+C key in keymap") - - queue_key = function(key, keytype) - assert(type(key) == "string", "queue_key: 'key' must be a string, got " .. type(key)) - assert(type(keytype) == "string", "queue_key: 'keytype' must be a string, got " .. type(keytype)) - table.insert(input_queue, { key = key, keytype = keytype }) - end - end) - before_each(function() - t = require("terminal") - - -- mock terminal - old_size = t.size - t.size = function() return 24, 80 end - - old_write = t.output.write - t.output.write = function() end - - old_print = t.output.print - t.output.print = function() end - - old_readansi = t.input.readansi - t.input.readansi = function() - if #input_queue > 0 then - local entry = table.remove(input_queue, 1) - return entry.key, entry.keytype - end - return nil, "timeout" - end - - input_queue = {} + helpers.load() Prompt = require("terminal.cli.prompt") end) after_each(function() - t.size = old_size - t.output.write = old_write - t.output.print = old_print - t.input.readansi = old_readansi - - package.loaded["terminal.cli.prompt"] = nil - Prompt = nil + helpers.unload() end) @@ -121,7 +52,7 @@ describe("terminal.cli.prompt", function() } -- Queue Esc key (from keymap) - queue_key(ESC_KEY, "ansi") + helpers._push_input(helpers.keys.esc) local result, err = prompt:run() @@ -138,7 +69,7 @@ describe("terminal.cli.prompt", function() } -- Queue Ctrl+C key (from keymap) - queue_key(CTRL_C_KEY, "char") + helpers._push_input(helpers.keys.ctrl_c) local result, err = prompt:run() @@ -159,7 +90,7 @@ describe("terminal.cli.prompt", function() } -- Queue Enter key (platform-specific) - queue_key(ENTER_KEY, "char") + helpers._push_input(helpers.keys.enter) local result, err = prompt:run() @@ -175,7 +106,7 @@ describe("terminal.cli.prompt", function() } -- Queue Enter key (platform-specific) - queue_key(ENTER_KEY, "char") + helpers._push_input(helpers.keys.enter) local result, err = prompt:run() @@ -185,4 +116,4 @@ describe("terminal.cli.prompt", function() end) -end) \ No newline at end of file +end) diff --git a/spec/helpers.lua b/spec/helpers.lua new file mode 100644 index 0000000..7297e6a --- /dev/null +++ b/spec/helpers.lua @@ -0,0 +1,279 @@ +--- Test helper for Busted specs: load and unload the terminal library (and LuaSystem) +-- for test isolation. Cleans from `package.loaded`: `"terminal"`, any `"terminal.*"`, +-- and `"system"`. Intended for use from Busted setup/teardown or before_each/after_each. +-- This module does not require terminal or system at load time. +-- @module spec.helpers + + + +local M = {} + +local terminal -- to hold the terminal module if loaded +local system -- to hold the system module if loaded + + + +-- nil safe versions of pack and unpack +local pack = require("pl.utils").pack +local unpack = require("pl.utils").unpack + + + +local get_config do + -- this config table holds config values as configured for the mock functions. + -- the key is the terminal module-table, the value a hash-table of config values. + -- Because it is set to weak-key, the table is only kept alive if the terminal module + -- is still in use, aftre a reload it will be a fresh copy again. + local config = setmetatable({}, { __mode = "k" }) + + + -- returns the mock config for the current modules (system and terminal) + -- every mock function should call this to ensure we run the asserts. + function get_config() + assert(terminal, "modules not loaded yet, first call 'load()'") + + local cfg = config[terminal] + if not cfg then + cfg = setmetatable({}, { + __index = function(self, key) + -- the top-level is an "auto-table", if there is no key, we create and return an empty table. + -- so we can reduce extensive checking elsewhere in this module + self[key] = {} + return self[key] + end + }) + config[terminal] = cfg + end + return cfg + end +end + + + +-- ==================================================================================================== +-- Mock functions for system and terminal +-- ==================================================================================================== + + + +-- Sets the terminal size. +-- @tparam number rows number of rows +-- @tparam number columns number of columns +function M.set_termsize(rows, columns) + assert(type(rows) == "number", "rows must be a number, got " .. type(rows)) + assert(type(columns) == "number", "columns must be a number, got " .. type(columns)) + + get_config().termsize = { + rows = rows, + columns = columns, + } +end + + + +--- Gets the terminal size. +-- This is the mock function for `system.termsize()`. +-- If the terminal size wasn't set yet, returns 25x80. +-- @treturn number rows number of rows +-- @treturn number columns number of columns +function M.get_termsize() + local cfg = get_config() + return cfg.termsize.rows or 25, cfg.termsize.columns or 80 +end + + + +--- Reads a single byute from the keyboard buffer, which is mocked. +-- This is the mock for `system._readkey()` +-- @treturn number the byte read from the keyboard buffer, or nil if the buffer is empty +function M._readkey() + local buffer = get_config().keyboardbuffer + local entry = table.remove(buffer, 1) or {} + return unpack(entry) +end + + + +--- Pushes input into the keyboard buffer mock. +-- @tparam string seq the sequence of input, individual bytes of this string will be returned +-- @tparam string err an eror to return, in this case `seq` MUST be nil. +function M._push_input(seq, err) + -- TODO: rename this function, remove underscore + local buffer = get_config().keyboardbuffer + + if type(seq) == "string" then + assert(err == nil, "error must be nil if seq is a string") + assert(seq ~= "", "seq must be a non-empty string") + for i = 1, #seq do + table.insert(buffer, pack(string.byte(seq, i))) + end + + elseif seq == nil then + assert(type(err) == "string", "err must be a string if seq is nil") + assert(err ~= "", "err must be a non-empty string") + table.insert(buffer, pack(nil, err)) + + else + error("invalid type for seq, must be a string or nil") + end +end + + + +-- Gets the output written to the output stream. +-- @treturn string the output written to the output stream, empty string if no output +-- was written yet. +function M.get_output() + local cfg = get_config() + if not cfg.output.filename then + return "" + end + return assert(require("pl.utils").readfile(cfg.output.filename)) +end + + + +--- Clears the output written to the output stream. +-- and recreates an empty output file. +function M.clear_output() + local cfg = get_config() + + -- close an existing file + if cfg.output.filehandle then + cfg.output.filehandle:close() + cfg.output.filehandle = nil + end + + -- remove an existing file, define name if none set yet + if cfg.output.filename then + os.remove(cfg.output.filename) + else + cfg.output.filename = require("pl.path").tmpname() + end + + -- reopen file, and set it as the output stream + cfg.output.filehandle = assert(io.open(cfg.output.filename, "wb")) + terminal.output.set_stream(cfg.output.filehandle) +end + + + +--- A lookup table for key sequences to push into the keyboard buffer. +-- @table keys +-- @usage +-- helper._push_input(helper.keys.esc) +-- helper._push_input(helper.keys.ctrl_c) +-- helper._push_input(helper.keys.enter) +M.keys = setmetatable({}, { + __newindex = function(self, key, value) + error("table is read-only", 2) + end, + __index = function(self, key) + get_config() -- to assert the module was loaded + local keyname = terminal.input.keymap.default_keys[key] -- should error if an unknown key + for raw, name in pairs(terminal.input.keymap.default_key_map) do + if name == keyname then + return raw + end + end + error("key " .. tostring(key) .. " not found in default key map", 2) -- should be impossible to reach + end +}) + + + +-- ==================================================================================================== +-- (Un)loading and patching system and terminal to enable mocks +-- ==================================================================================================== + + +-- Patches system to enable mocking +local function patch_system() + system.termsize = M.get_termsize + system._readkey = M._readkey +end + + + +-- Patches terminal to enable mocking +local function patch_terminal() + M.clear_output() + + -- disable changing the output stream + local set_stream = terminal.output.set_stream + terminal.output.set_stream = function(filehandle) + local cfg = get_config() -- the upvalue cfg might be outdated, need to get the latest one + if filehandle ~= cfg.output.filehandle then + return true + end + -- only set it if it matches the mocked filehandle + return set_stream(filehandle) + end +end + + + +-- Cleanup a config entry +local function clean_config() + local cfg = get_config() + + -- cleanup output files + if cfg.output.filehandle then + cfg.output.filehandle:close() + cfg.output.filehandle = nil + end + if cfg.output.filename then + os.remove(cfg.output.filename) + cfg.output.filename = nil + end +end + + + +--- Load the main terminal and system modules after cleaning package.loaded. +-- Cleans terminal and LuaSystem from `package.loaded`, then requires the main +-- `terminal` module. Will patch both libraries to enable the mocks. +-- Call from Busted setup or before_each. +-- @treturn table the main terminal module +function M.load() + M.unload() + + system = require("system") + patch_system() + + _G._TEST = true -- some modules export some private internals for testing + terminal = require("terminal") + patch_terminal() + + return terminal +end + + + +--- Remove terminal and LuaSystem from package.loaded. +-- Does not load them again. Call from Busted teardown or after_each so the +-- next load() gets a fresh terminal. Idempotent. +function M.unload() + -- clean up package.loaded + for key, _ in pairs(package.loaded) do + if key == "terminal" or + key == "system" or + (type(key) == "string" and key:match("^terminal%.")) then + package.loaded[key] = nil + end + end + -- cleanup any dangling stuff + if terminal then + clean_config() + end + terminal = nil + system = nil -- luacheck: ignore + _G._TEST = nil + -- call twice to ensure finalization is complete + collectgarbage() + collectgarbage() +end + + + +return M diff --git a/src/terminal/init.lua b/src/terminal/init.lua index c33a3d1..2a77aeb 100644 --- a/src/terminal/init.lua +++ b/src/terminal/init.lua @@ -32,7 +32,7 @@ local sys = require "system" -- Push the module table already in `package.loaded` to avoid circular dependencies package.loaded["terminal"] = M --- load the submodules +-- load the submodules; all but object; editline, sequence, cli.*, ui.* M.input = require("terminal.input") M.output = require("terminal.output") M.clear = require("terminal.clear") @@ -41,6 +41,7 @@ M.cursor = require("terminal.cursor") M.text = require("terminal.text") M.draw = require("terminal.draw") M.progress = require("terminal.progress") +M.utils = require("terminal.utils") -- create locals local output = M.output local scroll = M.scroll