diff --git a/.docker/ci.Dockerfile b/.docker/ci.Dockerfile index 3b483e1..a8bad23 100644 --- a/.docker/ci.Dockerfile +++ b/.docker/ci.Dockerfile @@ -1,4 +1,8 @@ FROM akorn/luarocks:lua5.4-alpine -RUN apk add gcc musl-dev -RUN luarocks install luacheck +RUN apk add gcc musl-dev make + +RUN luarocks install luacheck \ + && luarocks install luaunit + +WORKDIR shiftit diff --git a/.docker/docker-compose.ci.yaml b/.docker/docker-compose.ci.yaml index 9c77b53..15d6728 100644 --- a/.docker/docker-compose.ci.yaml +++ b/.docker/docker-compose.ci.yaml @@ -5,4 +5,4 @@ services: context: .. dockerfile: ./.docker/ci.Dockerfile volumes: - - ..:/shifit + - ..:/shiftit diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 45ceaaa..ffcceea 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,3 +16,6 @@ jobs: - name: Run linter run: make ci-lint + + - name: Run tests + run: make ci-test diff --git a/Makefile b/Makefile index 4fc9d1f..74efea2 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,13 @@ +test: + find . -name "*_test.lua" | xargs sh -c 'lua $$0 -v' + ci-init: docker-compose -f .docker/docker-compose.ci.yaml build ci-lint: - docker-compose -f .docker/docker-compose.ci.yaml run hammerspoon-shiftit-ci luacheck --globals hs -- shifit/ + docker-compose -f .docker/docker-compose.ci.yaml run hammerspoon-shiftit-ci luacheck --globals hs -- . + +ci-test: + docker-compose -f .docker/docker-compose.ci.yaml run hammerspoon-shiftit-ci make test .PHONY: ci-init ci-lint diff --git a/hammerspoon_mocks.lua b/hammerspoon_mocks.lua new file mode 100644 index 0000000..f6a527d --- /dev/null +++ b/hammerspoon_mocks.lua @@ -0,0 +1,61 @@ +local lu = require('luaunit') + +local hotkey = { + bindings = {}, +} + +local defaultScreenRect = { x = 0, y = 0, w = 1200, h = 800 } +local defaultWindowRect = { x = 100, y = 100, w = 1000, h = 600 } + +function hotkey.bind(mods, key, fn) + lu.assertNotNil(mods) + lu.assertNotNil(key) + lu.assertIsFunction(fn) + table.insert(hotkey.bindings, { mods, key, fn }) +end + +local screen = { + rect = defaultScreenRect, +} + +function screen:frame() + return self.rect +end + +local window = { + rect = defaultWindowRect, + _screen = screen, +} + +function window.focusedWindow() + return window +end + +function window:frame() + return self.rect +end + +function window:screen() + return self._screen +end + +function window:move(rect, _, _, _) + lu.assertIsNumber(rect.x) + lu.assertIsNumber(rect.y) + lu.assertIsNumber(rect.w) + lu.assertIsNumber(rect.h) + self.rect = rect +end + +local mocks = { + hotkey = hotkey, + window = window, +} + +function mocks:reset() + self.hotkey.bindings = {} + self.window.rect = defaultWindowRect + self.window._screen.rect = defaultScreenRect +end + +return mocks diff --git a/init.lua b/init.lua index 34042c7..8f4ec36 100644 --- a/init.lua +++ b/init.lua @@ -4,7 +4,9 @@ --- --- Download: https://github.com/peterklijn/hammerspoon-shiftit/raw/master/Spoons/ShiftIt.spoon.zip -local obj = {} +local obj = { + hs = hs +} obj.__index = obj -- Metadata @@ -48,11 +50,11 @@ local units = { maximum = { x = 0.00, y = 0.00, w = 1.00, h = 1.00 }, } -local function move(unit) hs.window.focusedWindow():move(unit, nil, true, 0) end +function obj:move(unit) self.hs.window.focusedWindow():move(unit, nil, true, 0) end -local function resizeWindowInSteps(increment) - local screen = hs.window.focusedWindow():screen():frame() - local window = hs.window.focusedWindow():frame() +function obj:resizeWindowInSteps(increment) + local screen = self.hs.window.focusedWindow():screen():frame() + local window = self.hs.window.focusedWindow():frame() local wStep = math.floor(screen.w / 12) local hStep = math.floor(screen.h / 12) local x, y, w, h = window.x, window.y, window.w, window.h @@ -67,7 +69,7 @@ local function resizeWindowInSteps(increment) w = math.min(screen.w - x + screen.x, w + wStep) h = math.min(screen.h - y + screen.y, h + hStep) else -local noChange = true + local noChange = true local notMinWidth = w > wStep * 3 local notMinHeight = h > hStep * 3 @@ -105,44 +107,44 @@ local noChange = true h = notMinHeight and h - hStep * 2 or h end end - hs.window.focusedWindow():move({ x = x, y = y, w = w, h = h }, nil, true, 0) + self:move({ x = x, y = y, w = w, h = h }) end -function obj.left() move(units.left50, nil, true, 0) end +function obj:left() self:move(units.left50) end -function obj.right() move(units.right50, nil, true, 0) end +function obj:right() self:move(units.right50) end -function obj.up() move(units.top50, nil, true, 0) end +function obj:up() self:move(units.top50) end -function obj.down() move(units.bot50, nil, true, 0) end +function obj:down() self:move(units.bot50) end -function obj.upleft() move(units.upleft50, nil, true, 0) end +function obj:upleft() self:move(units.upleft50) end -function obj.upright() move(units.upright50, nil, true, 0) end +function obj:upright() self:move(units.upright50) end -function obj.botleft() move(units.botleft50, nil, true, 0) end +function obj:botleft() self:move(units.botleft50) end -function obj.botright() move(units.botright50, nil, true, 0) end +function obj:botright() self:move(units.botright50) end -function obj.maximum() move(units.maximum, nil, true, 0) end +function obj:maximum() self:move(units.maximum) end -function obj.toggleFullScreen() hs.window.focusedWindow():toggleFullScreen() end +function obj:toggleFullScreen() self.hs.window.focusedWindow():toggleFullScreen() end -function obj.toggleZoom() hs.window.focusedWindow():toggleZoom() end +function obj:toggleZoom() self.hs.window.focusedWindow():toggleZoom() end -function obj.center() hs.window.focusedWindow():centerOnScreen(nil, true, 0) end +function obj:center() self.hs.window.focusedWindow():centerOnScreen(nil, true, 0) end -function obj.nextScreen() - hs.window.focusedWindow():moveToScreen(hs.window.focusedWindow():screen():next(), false, true, 0) +function obj:nextScreen() + self.hs.window.focusedWindow():moveToScreen(self.hs.window.focusedWindow():screen():next(), false, true, 0) end -function obj.previousScreen() - hs.window.focusedWindow():moveToScreen(hs.window.focusedWindow():screen():previous(), false, true, 0) +function obj:prevScreen() + self.hs.window.focusedWindow():moveToScreen(self.hs.window.focusedWindow():screen():previous(), false, true, 0) end -function obj.resizeOut() resizeWindowInSteps(true) end +function obj:resizeOut() self:resizeWindowInSteps(true) end -function obj.resizeIn() resizeWindowInSteps(false) end +function obj:resizeIn() self:resizeWindowInSteps(false) end --- HammerspoonShiftIt:bindHotkeys(mapping) --- Method @@ -172,24 +174,24 @@ function obj:bindHotkeys(mapping) for k, v in pairs(mapping) do self.mapping[k] = v end end - hs.hotkey.bind(self.mapping.left[1], self.mapping.left[2], function() self:left() end) - hs.hotkey.bind(self.mapping.right[1], self.mapping.right[2], function() self:right() end) - hs.hotkey.bind(self.mapping.up[1], self.mapping.up[2], function() self:up() end) - hs.hotkey.bind(self.mapping.down[1], self.mapping.down[2], function() self:down() end) - hs.hotkey.bind(self.mapping.upleft[1], self.mapping.upleft[2], function() self:upleft() end) - hs.hotkey.bind(self.mapping.upright[1], self.mapping.upright[2], function() self:upright() end) - hs.hotkey.bind(self.mapping.botleft[1], self.mapping.botleft[2], function() self:botleft() end) - hs.hotkey.bind(self.mapping.botright[1], self.mapping.botright[2], function() self:botright() end) - hs.hotkey.bind(self.mapping.maximum[1], self.mapping.maximum[2], function() self:maximum() end) - hs.hotkey.bind(self.mapping.toggleFullScreen[1], self.mapping.toggleFullScreen[2], function() + self.hs.hotkey.bind(self.mapping.left[1], self.mapping.left[2], function() self:left() end) + self.hs.hotkey.bind(self.mapping.right[1], self.mapping.right[2], function() self:right() end) + self.hs.hotkey.bind(self.mapping.up[1], self.mapping.up[2], function() self:up() end) + self.hs.hotkey.bind(self.mapping.down[1], self.mapping.down[2], function() self:down() end) + self.hs.hotkey.bind(self.mapping.upleft[1], self.mapping.upleft[2], function() self:upleft() end) + self.hs.hotkey.bind(self.mapping.upright[1], self.mapping.upright[2], function() self:upright() end) + self.hs.hotkey.bind(self.mapping.botleft[1], self.mapping.botleft[2], function() self:botleft() end) + self.hs.hotkey.bind(self.mapping.botright[1], self.mapping.botright[2], function() self:botright() end) + self.hs.hotkey.bind(self.mapping.maximum[1], self.mapping.maximum[2], function() self:maximum() end) + self.hs.hotkey.bind(self.mapping.toggleFullScreen[1], self.mapping.toggleFullScreen[2], function() self:toggleFullScreen() end) - hs.hotkey.bind(self.mapping.toggleZoom[1], self.mapping.toggleZoom[2], function() self:toggleZoom() end) - hs.hotkey.bind(self.mapping.center[1], self.mapping.center[2], function() self:center() end) - hs.hotkey.bind(self.mapping.nextScreen[1], self.mapping.nextScreen[2], function() self:nextScreen() end) - hs.hotkey.bind(self.mapping.previousScreen[1], self.mapping.previousScreen[2], function() self:previousScreen() end) - hs.hotkey.bind(self.mapping.resizeOut[1], self.mapping.resizeOut[2], function() self:resizeOut() end) - hs.hotkey.bind(self.mapping.resizeIn[1], self.mapping.resizeIn[2], function() self:resizeIn() end) + self.hs.hotkey.bind(self.mapping.toggleZoom[1], self.mapping.toggleZoom[2], function() self:toggleZoom() end) + self.hs.hotkey.bind(self.mapping.center[1], self.mapping.center[2], function() self:center() end) + self.hs.hotkey.bind(self.mapping.nextScreen[1], self.mapping.nextScreen[2], function() self:nextScreen() end) + self.hs.hotkey.bind(self.mapping.previousScreen[1], self.mapping.previousScreen[2], function() self:prevScreen() end) + self.hs.hotkey.bind(self.mapping.resizeOut[1], self.mapping.resizeOut[2], function() self:resizeOut() end) + self.hs.hotkey.bind(self.mapping.resizeIn[1], self.mapping.resizeIn[2], function() self:resizeIn() end) return self end diff --git a/init_test.lua b/init_test.lua new file mode 100644 index 0000000..1605e22 --- /dev/null +++ b/init_test.lua @@ -0,0 +1,214 @@ +local lu = require('luaunit') +local shiftit = require('init') +local hsmocks = require('hammerspoon_mocks') + +-- disable lint errors for mutating global variable TestShiftIt: +-- luacheck: ignore 112 + +TestShiftIt = {} -- luacheck: ignore 111 + +function TestShiftIt.setUp() + shiftit.hs = hsmocks + hsmocks:reset() +end + +function TestShiftIt.testBindDefault() + shiftit:bindHotkeys({}) + lu.assertEquals(#hsmocks.hotkey.bindings, 16) + + local expected = { + 'left', 'right', 'up', 'down', + '1', '2', '3', '4', 'm', + 'f', 'z', 'c', 'n', 'p', + '=', '-', + } + for i, item in pairs(expected) do + lu.assertEquals(hsmocks.hotkey.bindings[i][1], shiftit.mash) + lu.assertEquals(hsmocks.hotkey.bindings[i][2], item) + end +end + +function TestShiftIt.testBindOverrideVimKeys() + shiftit:bindHotkeys({ + left = { { 'ctrl', 'alt', 'cmd' }, 'h' }, + down = { { 'ctrl', 'alt', 'cmd' }, 'j' }, + up = { { 'ctrl', 'alt', 'cmd' }, 'k' }, + right = { { 'ctrl', 'alt', 'cmd' }, 'l' }, + }) + lu.assertEquals(#hsmocks.hotkey.bindings, 16) + + local expected = { + 'h', 'l', 'k', 'j', + '1', '2', '3', '4', 'm', + 'f', 'z', 'c', 'n', 'p', + '=', '-', + } + for i, item in pairs(expected) do + lu.assertEquals(hsmocks.hotkey.bindings[i][1], shiftit.mash) + lu.assertEquals(hsmocks.hotkey.bindings[i][2], item) + end +end + +function TestShiftIt.testResizeWindowInStepsStickingToSides() + local tests = { + { + desc = 'increase window sticking to left', + before = { x = 0, y = 0, w = 600, h = 800 }, + expect = { x = 0, y = 0, w = 700, h = 800 }, + increase = true, + }, + { + desc = 'decrease window sticking to left', + before = { x = 0, y = 0, w = 600, h = 800 }, + expect = { x = 0, y = 0, w = 500, h = 800 }, + increase = false, + }, + { + desc = 'increase window sticking to right', + before = { x = 600, y = 0, w = 600, h = 800 }, + expect = { x = 500, y = 0, w = 700, h = 800 }, + increase = true, + }, + { + desc = 'decrease window sticking to right', + before = { x = 600, y = 0, w = 600, h = 800 }, + expect = { x = 700, y = 0, w = 500, h = 800 }, + increase = false, + }, + { + desc = 'increase window sticking to top', + before = { x = 0, y = 0, w = 1200, h = 400 }, + expect = { x = 0, y = 0, w = 1200, h = 466 }, + increase = true, + }, + { + desc = 'decrease window sticking to top', + before = { x = 0, y = 0, w = 1200, h = 400 }, + expect = { x = 0, y = 0, w = 1200, h = 334 }, + increase = false, + }, + { + desc = 'increase window sticking to bottom', + before = { x = 0, y = 400, w = 1200, h = 400 }, + expect = { x = 0, y = 334, w = 1200, h = 466 }, + increase = true, + }, + { + desc = 'decrease window sticking to bottom', + before = { x = 0, y = 400, w = 1200, h = 400 }, + expect = { x = 0, y = 466, w = 1200, h = 334 }, + increase = false, + }, + } + for _, test in pairs(tests) do + hsmocks.window.rect = test.before + shiftit:resizeWindowInSteps(test.increase) + lu.assertEquals(hsmocks.window.rect, test.expect, test.desc) + end +end + +function TestShiftIt.testResizeWindowInStepsStickingToCorners() + local tests = { + { + desc = 'increase window sticking to left top', + before = { x = 0, y = 0, w = 600, h = 400 }, + expect = { x = 0, y = 0, w = 700, h = 466 }, + increase = true, + }, + { + desc = 'decrease window sticking to left top', + before = { x = 0, y = 0, w = 600, h = 400 }, + expect = { x = 0, y = 0, w = 500, h = 334 }, + increase = false, + }, + { + desc = 'increase window sticking to right top', + before = { x = 600, y = 0, w = 600, h = 400 }, + expect = { x = 500, y = 0, w = 700, h = 466 }, + increase = true, + }, + { + desc = 'decrease window sticking to right top', + before = { x = 600, y = 0, w = 600, h = 400 }, + expect = { x = 700, y = 0, w = 500, h = 334 }, + increase = false, + }, + { + desc = 'increase window sticking to left bottom', + before = { x = 0, y = 400, w = 600, h = 400 }, + expect = { x = 0, y = 334, w = 700, h = 466 }, + increase = true, + }, + { + desc = 'decrease window sticking to left bottom', + before = { x = 0, y = 400, w = 600, h = 400 }, + expect = { x = 0, y = 466, w = 500, h = 334 }, + increase = false, + }, + { + desc = 'increase window sticking to right bottom', + before = { x = 0, y = 400, w = 600, h = 400 }, + expect = { x = 0, y = 334, w = 700, h = 466 }, + increase = true, + }, + { + desc = 'decrease window sticking to right bottom', + before = { x = 600, y = 400, w = 600, h = 400 }, + expect = { x = 700, y = 466, w = 500, h = 334 }, + increase = false, + }, + } + for _, test in pairs(tests) do + hsmocks.window.rect = test.before + shiftit:resizeWindowInSteps(test.increase) + lu.assertEquals(hsmocks.window.rect, test.expect, test.desc) + end +end + +function TestShiftIt.testResizeWindowInStepsEdgeCases() + local tests = { + { + desc = 'does not exceed screen width', + before = { x = 5, y = 200, w = 1190, h = 400 }, + expect = { x = 0, y = 134, w = 1200, h = 532 }, + increase = true, + }, + { + desc = 'does not exceed screen height', + before = { x = 200, y = 5, w = 800, h = 790 }, + expect = { x = 100, y = 0, w = 1000, h = 800 }, + increase = true, + }, + { + desc = 'does not exceed screen size', + before = { x = 5, y = 5, w = 1190, h = 790 }, + expect = { x = 0, y = 0, w = 1200, h = 800 }, + increase = true, + }, + { + desc = 'does not become too narrow', + before = { x = 0, y = 0, w = 300, h = 800 }, + expect = { x = 0, y = 66, w = 300, h = 668 }, + increase = false, + }, + { + desc = 'does not become too short', + before = { x = 0, y = 0, w = 1200, h = 198 }, + expect = { x = 100, y = 0, w = 1000, h = 198 }, + increase = false, + }, + { + desc = 'does not become too tiny', + before = { x = 100, y = 100, w = 300, h = 198 }, + expect = { x = 100, y = 100, w = 300, h = 198 }, + increase = false, + }, + } + for _, test in pairs(tests) do + hsmocks.window.rect = test.before + shiftit:resizeWindowInSteps(test.increase) + lu.assertEquals(hsmocks.window.rect, test.expect, test.desc) + end +end + +os.exit(lu.LuaUnit.run())