diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b9998df..d276633 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,6 +23,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y lua5.4 liblua5.4-dev cmake + sudo ln -sf /usr/bin/lua5.4 /usr/bin/lua - uses: extractions/setup-just@v2 @@ -43,9 +44,12 @@ jobs: - uses: extractions/setup-just@v2 - - name: Install GitHub CLI (if missing) + - name: Install GitHub CLI and Lua (if missing) run: | which gh || sudo apt-get install -y gh + sudo apt-get update + sudo apt-get install -y lua5.4 liblua5.4-dev + sudo ln -sf /usr/bin/lua5.4 /usr/bin/lua - name: Check formatting run: | diff --git a/.luacov b/.luacov new file mode 100644 index 0000000..60a4e26 --- /dev/null +++ b/.luacov @@ -0,0 +1,9 @@ +return { + include = { + "lua/roda/.*" + }, + exclude = { + "spec/.*", + "%.lux/.*" + } +} \ No newline at end of file diff --git a/justfile b/justfile index 49c9e77..985b262 100644 --- a/justfile +++ b/justfile @@ -94,7 +94,7 @@ lint: [doc("Lint for CI (Lua 5.4)")] [group('ci')] lint-ci: - lx --lua-version 5.4 lint + lx --lua-version 5.4 --lua-dir {{ lua_prefix }} lint [doc("Run code quality checks")] [group('dev')] @@ -112,11 +112,21 @@ test-unit: build-luv [group('test')] test: test-unit +[doc("Run unit tests with coverage and generate report")] +[group('test')] +test-coverage: build-luv + @echo "Running unit tests with coverage..." + LUA_CPATH="{{ build_dir }}/?.so;;" lx --lua-version 5.5 --lua-dir {{ lua_prefix }} test -- --coverage + @echo "Generating coverage report..." + @luacov_src=$(find .lux/5.5/test_dependencies/5.5 -maxdepth 1 -name '*luacov*' -type d -print -quit 2>/dev/null)/src; \ + lx exec --no-loader lua -- -e "package.path = package.path .. ';"$luacov_src"/?.lua'; local r = require('luacov.runner'); r.run_report(r.load_config())" + @echo "Coverage report written to luacov.report.out" + [doc("Run unit tests for CI (Lua 5.4)")] [group('ci')] test-ci: build-luv @echo "Running unit tests for CI..." - LUA_CPATH="{{ build_dir }}/?.so;;" lx --lua-version 5.4 test + LUA_CPATH="{{ build_dir }}/?.so;;" lx --lua-version 5.4 --lua-dir {{ lua_prefix }} test # --- Build --- diff --git a/lua/roda/util.lua b/lua/roda/util.lua index e6d213e..179f147 100644 --- a/lua/roda/util.lua +++ b/lua/roda/util.lua @@ -15,16 +15,24 @@ function M.bracket(acquire, release) return function(use) return function(on_complete) local resource = acquire() - use( - resource, - -- Release the resource and trigger the final continuation - function(...) - release(resource) - if on_complete then - on_complete(...) - end + local released = false + + local function safe_release(...) + if released then + return + end + released = true + release(resource) + if on_complete then + on_complete(...) end - ) + end + + local success, err = pcall(use, resource, safe_release) + if not success then + safe_release() + error(err, 0) + end end end end diff --git a/lux.toml b/lux.toml index 6c97442..f61382d 100644 --- a/lux.toml +++ b/lux.toml @@ -24,6 +24,7 @@ dev = "git+https://github.com/tkolleh/roda.lua.git" [test_dependencies] busted = "2.3.0-1" +luacov = ">= 0.15.0" [test] type = "busted" diff --git a/spec/roda_spec.lua b/spec/roda_spec.lua index d98e107..c652988 100644 --- a/spec/roda_spec.lua +++ b/spec/roda_spec.lua @@ -1,18 +1,17 @@ ---@diagnostic disable: undefined-global local roda = require("roda") -describe("roda module", function() - -- Mock stream to capture output without affecting terminal - local function mock_stream() - return { - output = {}, - write = function(self, str) - table.insert(self.output, str) - end, - flush = function() end, - } - end +local function mock_stream() + return { + output = {}, + write = function(self, str) + table.insert(self.output, str) + end, + flush = function() end, + } +end +describe("roda module", function() describe("constructor", function() it("should create spinner with string text", function() local spinner = roda("Loading...") @@ -367,6 +366,57 @@ describe("roda.promise", function() end) end) +describe("roda.roda alias", function() + it("should create a spinner when called directly", function() + local spinner = roda.roda("Direct call") + assert.is_not_nil(spinner) + assert.equals("Direct call", spinner:getText()) + end) + + it("should behave identically to roda.new", function() + local s1 = roda.roda({ text = "test", color = "green" }) + local s2 = roda.new({ text = "test", color = "green" }) + assert.equals(s1:getText(), s2:getText()) + assert.equals(s1:getColor(), s2:getColor()) + end) +end) + +describe("render edge cases", function() + it("should return self when render called while not spinning", function() + local stream = mock_stream() + local spinner = roda("Test") + spinner._stream = stream + local result = spinner:render() + assert.equals(spinner, result) + assert.equals(0, #stream.output) + end) + + it("should wrap frame index after reaching the end", function() + local stream = mock_stream() + local spinner = roda({ spinner = "line" }) + spinner._stream = stream + spinner:start() + local frames_count = #spinner._spinner.frames + for _ = 1, frames_count - 1 do + spinner:render() + end + assert.equals(1, spinner._frame_index) + spinner:stop() + end) + + it("should render when setText is called while spinning", function() + local stream = mock_stream() + local spinner = roda("Original") + spinner._stream = stream + spinner:start() + local count_before = #stream.output + spinner:setText("Updated") + assert.is_true(#stream.output > count_before) + assert.equals("Updated", spinner:getText()) + spinner:stop() + end) +end) + describe("submodule exports", function() it("should export ansi submodule", function() assert.is_table(roda.ansi) diff --git a/spec/util_spec.lua b/spec/util_spec.lua new file mode 100644 index 0000000..ee50edc --- /dev/null +++ b/spec/util_spec.lua @@ -0,0 +1,140 @@ +---@diagnostic disable: undefined-global +local fp = require("roda.util") + +describe("roda.util", function() + describe("bracket", function() + it("should acquire resource and pass it to use", function() + local acquired = false + local resource_value = "test-resource" + local received_resource = nil + + local bracket = fp.bracket(function() + acquired = true + return resource_value + end, function() end) + + local use_fn = bracket(function(resource, done) + received_resource = resource + done() + end) + + use_fn(function() end) + + assert.is_true(acquired) + assert.equals(resource_value, received_resource) + end) + + it("should release resource via release callback", function() + local released = false + local released_resource = nil + + local bracket = fp.bracket(function() + return "res" + end, function(r) + released = true + released_resource = r + end) + + local use_fn = bracket(function(resource, done) + done("result") + end) + + use_fn(function() end) + + assert.is_true(released) + assert.equals("res", released_resource) + end) + + it("should pass callback values to on_complete", function() + local on_complete_result = nil + + local bracket = fp.bracket(function() + return "res" + end, function() end) + + local use_fn = bracket(function(resource, done) + done("value1", "value2") + end) + + use_fn(function(...) + on_complete_result = { ... } + end) + + assert.equals("value1", on_complete_result[1]) + assert.equals("value2", on_complete_result[2]) + end) + + it("should call release before on_complete", function() + local call_order = {} + + local bracket = fp.bracket(function() + return "res" + end, function() + table.insert(call_order, "release") + end) + + local use_fn = bracket(function(resource, done) + done() + end) + + use_fn(function() + table.insert(call_order, "on_complete") + end) + + assert.equals("release", call_order[1]) + assert.equals("on_complete", call_order[2]) + end) + + it("should not call on_complete when it is nil", function() + local bracket = fp.bracket(function() + return "res" + end, function() end) + + local use_fn = bracket(function(resource, done) + done("result") + end) + + assert.has_no.errors(function() + use_fn(nil) + end) + end) + + it("should call release only once even if done is called multiple times", function() + local release_count = 0 + local bracket = fp.bracket(function() + return "res" + end, function() + release_count = release_count + 1 + end) + + local use_fn = bracket(function(resource, done) + done() + done() + done() + end) + + use_fn(function() end) + + assert.equals(1, release_count) + end) + + it("should call release and re-throw if use throws a synchronous error", function() + local released = false + local bracket = fp.bracket(function() + return "res" + end, function() + released = true + end) + + local use_fn = bracket(function(resource, done) + error("synchronous error") + end) + + assert.has_error(function() + use_fn(function() end) + end, "synchronous error") + + assert.is_true(released) + end) + end) +end)