From cbcd4f6acdd6f19d592a0cc494082247370d26aa Mon Sep 17 00:00:00 2001 From: elsid Date: Fri, 28 Feb 2025 23:58:35 +0100 Subject: [PATCH 1/3] Move matchers to different module --- .../integration_tests/test_lua_api/player.lua | 11 +-- .../testing_util/matchers.lua | 79 +++++++++++++++++++ .../testing_util/testing_util.lua | 70 ---------------- scripts/data/morrowind_tests/player.lua | 51 ++++++------ 4 files changed, 111 insertions(+), 100 deletions(-) create mode 100644 scripts/data/integration_tests/testing_util/matchers.lua diff --git a/scripts/data/integration_tests/test_lua_api/player.lua b/scripts/data/integration_tests/test_lua_api/player.lua index 74769da8e0..0b481fba2b 100644 --- a/scripts/data/integration_tests/test_lua_api/player.lua +++ b/scripts/data/integration_tests/test_lua_api/player.lua @@ -6,6 +6,7 @@ local input = require('openmw.input') local types = require('openmw.types') local nearby = require('openmw.nearby') local camera = require('openmw.camera') +local matchers = require('matchers') types.Player.setControlSwitch(self, types.Player.CONTROL_SWITCH.Controls, false) types.Player.setControlSwitch(self, types.Player.CONTROL_SWITCH.Fighting, false) @@ -113,13 +114,13 @@ testing.registerLocalTest('player rotation', coroutine.yield() local alpha1, gamma1 = self.rotation:getAnglesXZ() - testing.expectThat(alpha1, testing.isNotNan(), 'Alpha rotation in XZ convention is nan') - testing.expectThat(gamma1, testing.isNotNan(), 'Gamma rotation in XZ convention is nan') + testing.expectThat(alpha1, matchers.isNotNan(), 'Alpha rotation in XZ convention is nan') + testing.expectThat(gamma1, matchers.isNotNan(), 'Gamma rotation in XZ convention is nan') local alpha2, beta2, gamma2 = self.rotation:getAnglesZYX() - testing.expectThat(alpha2, testing.isNotNan(), 'Alpha rotation in ZYX convention is nan') - testing.expectThat(beta2, testing.isNotNan(), 'Beta rotation in ZYX convention is nan') - testing.expectThat(gamma2, testing.isNotNan(), 'Gamma rotation in ZYX convention is nan') + testing.expectThat(alpha2, matchers.isNotNan(), 'Alpha rotation in ZYX convention is nan') + testing.expectThat(beta2, matchers.isNotNan(), 'Beta rotation in ZYX convention is nan') + testing.expectThat(gamma2, matchers.isNotNan(), 'Gamma rotation in ZYX convention is nan') end end) diff --git a/scripts/data/integration_tests/testing_util/matchers.lua b/scripts/data/integration_tests/testing_util/matchers.lua new file mode 100644 index 0000000000..efb9e11587 --- /dev/null +++ b/scripts/data/integration_tests/testing_util/matchers.lua @@ -0,0 +1,79 @@ +local module = {} + +--- +-- Matcher verifying that distance between given value and expected is not greater than maxDistance. +-- @function elementsAreArray +-- @param expected#vector. +-- @usage +-- expectThat(util.vector2(0, 0), closeToVector(util.vector2(0, 1), 1)) +function module.closeToVector(expected, maxDistance) + return function(actual) + local distance = (expected - actual):length() + if distance <= maxDistance then + return '' + end + return string.format('%s is too far from expected %s: %s > %s', actual, expected, distance, maxDistance) + end +end + +--- +-- Matcher verifying that given value is an array each element of which matches elements of expected. +-- @function elementsAreArray +-- @param expected#array of values or matcher functions. +-- @usage +-- local t = {42, 13} +-- local matcher = function(actual) +-- if actual ~= 42 then +-- return string.format('%s is not 42', actual) +-- end +-- return '' +-- end +-- expectThat({42, 13}, elementsAreArray({matcher, 13})) +function module.elementsAreArray(expected) + local expected_matchers = {} + for i, v in ipairs(expected) do + if type(v) == 'function' then + expected_matchers[i] = v + else + expected_matchers[i] = function (other) + if expected[i].__eq(expected[i], other) then + return '' + end + return string.format('%s element %s does no match expected: %s', i, other, expected[i]) + end + end + end + return function(actual) + if #actual < #expected_matchers then + return string.format('number of elements is less than expected: %s < %s', #actual, #expected_matchers) + end + local message = '' + for i, v in ipairs(actual) do + if i > #expected_matchers then + message = string.format('%s\n%s element is out of expected range: %s', message, i, #expected_matchers) + break + end + local match_message = expected_matchers[i](v) + if match_message ~= '' then + message = string.format('%s\n%s', message, match_message) + end + end + return message + end +end + +--- +-- Matcher verifying that given number is not a nan. +-- @function isNotNan +-- @usage +-- expectThat(value, isNotNan()) +function module.isNotNan() + return function(actual) + if actual ~= actual then + return 'actual value is nan, expected to be not nan' + end + return '' + end +end + +return module diff --git a/scripts/data/integration_tests/testing_util/testing_util.lua b/scripts/data/integration_tests/testing_util/testing_util.lua index 5a69cb89ed..c894fa96f4 100644 --- a/scripts/data/integration_tests/testing_util/testing_util.lua +++ b/scripts/data/integration_tests/testing_util/testing_util.lua @@ -158,76 +158,6 @@ function M.expectNotEqual(v1, v2, msg) end end -function M.closeToVector(expected, maxDistance) - return function(actual) - local distance = (expected - actual):length() - if distance <= maxDistance then - return '' - end - return string.format('%s is too far from expected %s: %s > %s', actual, expected, distance, maxDistance) - end -end - ---- --- Matcher verifying that given value is an array each element of which matches elements of expected. --- @function elementsAreArray --- @param expected#array of values or matcher functions. --- @usage --- local t = {42, 13} --- local matcher = function(actual) --- if actual ~= 42 then --- return string.format('%s is not 42', actual) --- end --- return '' --- end --- expectThat({42, 13}, elementsAreArray({matcher, 13})) -function M.elementsAreArray(expected) - local expected_matchers = {} - for i, v in ipairs(expected) do - if type(v) == 'function' then - expected_matchers[i] = v - else - expected_matchers[i] = function (other) - if expected[i].__eq(expected[i], other) then - return '' - end - return string.format('%s element %s does no match expected: %s', i, other, expected[i]) - end - end - end - return function(actual) - if #actual < #expected_matchers then - return string.format('number of elements is less than expected: %s < %s', #actual, #expected_matchers) - end - local message = '' - for i, v in ipairs(actual) do - if i > #expected_matchers then - message = string.format('%s\n%s element is out of expected range: %s', message, i, #expected_matchers) - break - end - local match_message = expected_matchers[i](v) - if match_message ~= '' then - message = string.format('%s\n%s', message, match_message) - end - end - return message - end -end - ---- --- Matcher verifying that given number is not a nan. --- @function isNotNan --- @usage --- expectThat(value, isNotNan()) -function M.isNotNan(expected) - return function(actual) - if actual ~= actual then - return 'actual value is nan, expected to be not nan' - end - return '' - end -end - --- -- Verifies that given value matches provided matcher. -- @function expectThat diff --git a/scripts/data/morrowind_tests/player.lua b/scripts/data/morrowind_tests/player.lua index 226d9754f0..fcb1126c1b 100644 --- a/scripts/data/morrowind_tests/player.lua +++ b/scripts/data/morrowind_tests/player.lua @@ -5,6 +5,7 @@ local testing = require('testing_util') local util = require('openmw.util') local types = require('openmw.types') local nearby = require('openmw.nearby') +local matchers = require('matchers') types.Player.setControlSwitch(self, types.Player.CONTROL_SWITCH.Fighting, false) types.Player.setControlSwitch(self, types.Player.CONTROL_SWITCH.Jumping, false) @@ -45,33 +46,33 @@ testing.registerLocalTest('Guard in Imperial Prison Ship should find path (#7241 testing.expectLessOrEqual((util.vector2(path[#path].x, path[#path].y) - util.vector2(dst.x, dst.y)):length(), 1, 'Last path point x, y') testing.expectLessOrEqual(path[#path].z - dst.z, 20, 'Last path point z') if agentBounds.shapeType == nearby.COLLISION_SHAPE_TYPE.Aabb then - testing.expectThat(path, testing.elementsAreArray({ - testing.closeToVector(util.vector3(34.29737091064453125, 806.3817138671875, 112.76610565185546875), 1e-1), - testing.closeToVector(util.vector3(15, 1102, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-112, 1110, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-118, 1393, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-67.99993896484375, 1421.2000732421875, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-33.999935150146484375, 1414.4000244140625, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-6.79993534088134765625, 1380.4000244140625, 85.094573974609375), 1e-1), - testing.closeToVector(util.vector3(79, 724, -104.83390045166015625), 1e-1), - testing.closeToVector(util.vector3(84, 290.000030517578125, -104.83390045166015625), 1e-1), - testing.closeToVector(util.vector3(83.552001953125, 42.26399993896484375, -104.58989715576171875), 1e-1), - testing.closeToVector(util.vector3(89, -105, -98.72841644287109375), 1e-1), - testing.closeToVector(util.vector3(90, -90, -99.7056884765625), 1e-1), + testing.expectThat(path, matchers.elementsAreArray({ + matchers.closeToVector(util.vector3(34.29737091064453125, 806.3817138671875, 112.76610565185546875), 1e-1), + matchers.closeToVector(util.vector3(15, 1102, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-112, 1110, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-118, 1393, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-67.99993896484375, 1421.2000732421875, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-33.999935150146484375, 1414.4000244140625, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-6.79993534088134765625, 1380.4000244140625, 85.094573974609375), 1e-1), + matchers.closeToVector(util.vector3(79, 724, -104.83390045166015625), 1e-1), + matchers.closeToVector(util.vector3(84, 290.000030517578125, -104.83390045166015625), 1e-1), + matchers.closeToVector(util.vector3(83.552001953125, 42.26399993896484375, -104.58989715576171875), 1e-1), + matchers.closeToVector(util.vector3(89, -105, -98.72841644287109375), 1e-1), + matchers.closeToVector(util.vector3(90, -90, -99.7056884765625), 1e-1), })) elseif agentBounds.shapeType == nearby.COLLISION_SHAPE_TYPE.Cylinder then - testing.expectThat(path, testing.elementsAreArray({ - testing.closeToVector(util.vector3(34.29737091064453125, 806.3817138671875, 112.76610565185546875), 1e-1), - testing.closeToVector(util.vector3(-13.5999355316162109375, 1060.800048828125, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-27.1999359130859375, 1081.2000732421875, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-81.59993743896484375, 1128.800048828125, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-101.99993896484375, 1156.0001220703125, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(-118, 1393, 112.2945709228515625), 1e-1), - testing.closeToVector(util.vector3(7, 1470, 114.73973846435546875), 1e-1), - testing.closeToVector(util.vector3(79, 724, -104.83390045166015625), 1e-1), - testing.closeToVector(util.vector3(84, 290.000030517578125, -104.83390045166015625), 1e-1), - testing.closeToVector(util.vector3(95, 27, -104.83390045166015625), 1e-1), - testing.closeToVector(util.vector3(90, -90, -104.83390045166015625), 1e-1), + testing.expectThat(path, matchers.elementsAreArray({ + matchers.closeToVector(util.vector3(34.29737091064453125, 806.3817138671875, 112.76610565185546875), 1e-1), + matchers.closeToVector(util.vector3(-13.5999355316162109375, 1060.800048828125, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-27.1999359130859375, 1081.2000732421875, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-81.59993743896484375, 1128.800048828125, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-101.99993896484375, 1156.0001220703125, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(-118, 1393, 112.2945709228515625), 1e-1), + matchers.closeToVector(util.vector3(7, 1470, 114.73973846435546875), 1e-1), + matchers.closeToVector(util.vector3(79, 724, -104.83390045166015625), 1e-1), + matchers.closeToVector(util.vector3(84, 290.000030517578125, -104.83390045166015625), 1e-1), + matchers.closeToVector(util.vector3(95, 27, -104.83390045166015625), 1e-1), + matchers.closeToVector(util.vector3(90, -90, -104.83390045166015625), 1e-1), })) end end) From 536325e0ba54738453aada9666f129ab0c674de2 Mon Sep 17 00:00:00 2001 From: elsid Date: Fri, 28 Feb 2025 23:51:58 +0100 Subject: [PATCH 2/3] Add test for saving and loading the game --- .../integration_tests/test_lua_api/menu.lua | 44 +++++- .../testing_util/matchers.lua | 139 ++++++++++++++++++ 2 files changed, 178 insertions(+), 5 deletions(-) diff --git a/scripts/data/integration_tests/test_lua_api/menu.lua b/scripts/data/integration_tests/test_lua_api/menu.lua index 37f7cb826e..8316761765 100644 --- a/scripts/data/integration_tests/test_lua_api/menu.lua +++ b/scripts/data/integration_tests/test_lua_api/menu.lua @@ -1,12 +1,46 @@ local testing = require('testing_util') +local matchers = require('matchers') local menu = require('openmw.menu') +testing.registerMenuTest('save and load', function() + menu.newGame() + coroutine.yield() + menu.saveGame('save and load') + coroutine.yield() + + local directorySaves = {} + directorySaves['save_and_load.omwsave'] = { + playerName = '', + playerLevel = 1, + timePlayed = 0, + description = 'save and load', + contentFiles = { + 'builtin.omwscripts', + 'template.omwgame', + 'landracer.omwaddon', + 'the_hub.omwaddon', + 'test_lua_api.omwscripts', + }, + creationTime = matchers.isAny(), + } + local expectedAllSaves = {} + expectedAllSaves[' - 1'] = directorySaves + + testing.expectThat(menu.getAllSaves(), matchers.equalTo(expectedAllSaves)) + + menu.loadGame(' - 1', 'save_and_load.omwsave') + coroutine.yield() + + menu.deleteGame(' - 1', 'save_and_load.omwsave') + testing.expectThat(menu.getAllSaves(), matchers.equalTo({})) +end) + local function registerGlobalTest(name, description) - testing.registerMenuTest(description or name, function() - menu.newGame() - coroutine.yield() - testing.runGlobalTest(name) - end) + testing.registerMenuTest(description or name, function() + menu.newGame() + coroutine.yield() + testing.runGlobalTest(name) + end) end registerGlobalTest('timers') diff --git a/scripts/data/integration_tests/testing_util/matchers.lua b/scripts/data/integration_tests/testing_util/matchers.lua index efb9e11587..c7643af206 100644 --- a/scripts/data/integration_tests/testing_util/matchers.lua +++ b/scripts/data/integration_tests/testing_util/matchers.lua @@ -76,4 +76,143 @@ function module.isNotNan() end end +--- +-- Matcher accepting any value. +-- @function isAny +-- @usage +-- expectThat(value, isAny()) +function module.isAny() + return function(actual) + return '' + end +end + +local function serializeArray(a) + local result = nil + for _, v in ipairs(a) do + if result == nil then + result = string.format('{%s', serialize(v)) + else + result = string.format('%s, %s', result, serialize(v)) + end + end + if result == nil then + return '{}' + end + return string.format('%s}', result) +end + +local function serializeTable(t) + local result = nil + for k, v in pairs(t) do + if result == nil then + result = string.format('{%q = %s', k, serialize(v)) + else + result = string.format('%s, %q = %s', result, k, serialize(v)) + end + end + if result == nil then + return '{}' + end + return string.format('%s}', result) +end + +local function isArray(t) + local i = 1 + for _ in pairs(t) do + if t[i] == nil then + return false + end + i = i + 1 + end + return true +end + +function serialize(v) + local t = type(v) + if t == 'string' then + return string.format('%q', v) + elseif t == 'table' then + if isArray(v) then + return serializeArray(v) + end + return serializeTable(v) + end + return string.format('%s', v) +end + +local function compareScalars(v1, v2) + if v1 == v2 then + return '' + end + if type(v1) == 'string' then + return string.format('%q ~= %q', v1, v2) + end + return string.format('%s ~= %s', v1, v2) +end + +local function collectKeys(t) + local result = {} + for key in pairs(t) do + table.insert(result, key) + end + table.sort(result) + return result +end + +local function compareTables(t1, t2) + local keys1 = collectKeys(t1) + local keys2 = collectKeys(t2) + if #keys1 ~= #keys2 then + return string.format('table size mismatch: %d ~= %d', #keys1, #keys2) + end + for i = 1, #keys1 do + local key1 = keys1[i] + local key2 = keys2[i] + if key1 ~= key2 then + return string.format('table keys mismatch: %q ~= %q', key1, key2) + end + local d = compare(t1[key1], t2[key2]) + if d ~= '' then + return string.format('table values mismatch at key %s: %s', serialize(key1), d) + end + end + return '' +end + +function compare(v1, v2) + local type1 = type(v1) + local type2 = type(v2) + if type2 == 'function' then + return v2(v1) + end + if type1 ~= type2 then + return string.format('types mismatch: %s ~= %s', type1, type2) + end + if type1 == 'nil' then + return '' + elseif type1 == 'table' then + return compareTables(v1, v2) + elseif type1 == 'nil' or type1 == 'boolean' or type1 == 'number' or type1 == 'string' then + return compareScalars(v1, v2) + end + error('unsupported type: %s', type1) +end + +--- +-- Matcher verifying that given value is equal to expected. Accepts nil, boolean, number, string and table or matcher +-- function. +-- @function equalTo +-- @usage +-- expectThat({a = {42, 'foo', {b = true}}}, equalTo({a = {42, 'foo', {b = true}}})) +function module.equalTo(expected) + return function(actual) + local diff = compare(actual, expected) + if diff == '' then + return '' + end + return string.format('%s; actual: %s; expected: %s', diff, serialize(actual, ''), serialize(expected, '')) + end +end + return module From 2ebdc43bbe19514029ab080e1e80e6d80285a37b Mon Sep 17 00:00:00 2001 From: elsid Date: Fri, 7 Mar 2025 22:24:39 +0100 Subject: [PATCH 3/3] Add test for load while teleporting To reproduce #8311. Load game while landracer is scheduled to teleport from different cell. --- .../integration_tests/test_lua_api/global.lua | 18 ++++++++++++++++++ .../integration_tests/test_lua_api/menu.lua | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/scripts/data/integration_tests/test_lua_api/global.lua b/scripts/data/integration_tests/test_lua_api/global.lua index cc8240554a..225660b858 100644 --- a/scripts/data/integration_tests/test_lua_api/global.lua +++ b/scripts/data/integration_tests/test_lua_api/global.lua @@ -326,6 +326,24 @@ testing.registerGlobalTest('player weapon attack', function() testing.runLocalTest(player, 'player weapon attack') end) +testing.registerGlobalTest('load while teleporting - init player', function() + local player = world.players[1] + player:teleport('Museum of Wonders', util.vector3(0, -1500, 111), util.transform.rotateZ(math.rad(180))) +end) + +testing.registerGlobalTest('load while teleporting - teleport', function() + local player = world.players[1] + local landracer = world.createObject('landracer') + landracer:teleport(player.cell, player.position + util.vector3(0, 500, 0)) + coroutine.yield() + + local door = world.getObjectByFormId(core.getFormId('the_hub.omwaddon', 26)) + door:activateBy(player) + coroutine.yield() + + landracer:teleport(player.cell, player.position) +end) + return { engineHandlers = { onUpdate = testing.updateGlobal, diff --git a/scripts/data/integration_tests/test_lua_api/menu.lua b/scripts/data/integration_tests/test_lua_api/menu.lua index 8316761765..8c6895a5d8 100644 --- a/scripts/data/integration_tests/test_lua_api/menu.lua +++ b/scripts/data/integration_tests/test_lua_api/menu.lua @@ -35,6 +35,23 @@ testing.registerMenuTest('save and load', function() testing.expectThat(menu.getAllSaves(), matchers.equalTo({})) end) +testing.registerMenuTest('load while teleporting', function() + menu.newGame() + coroutine.yield() + + testing.runGlobalTest('load while teleporting - init player') + + menu.saveGame('load while teleporting') + coroutine.yield() + + testing.runGlobalTest('load while teleporting - teleport') + + menu.loadGame(' - 1', 'load_while_teleporting.omwsave') + coroutine.yield() + + menu.deleteGame(' - 1', 'load_while_teleporting.omwsave') +end) + local function registerGlobalTest(name, description) testing.registerMenuTest(description or name, function() menu.newGame()