From 76160076db9a5a6cc17212e88df2721c69b89794 Mon Sep 17 00:00:00 2001 From: Petr Mikheev Date: Sat, 10 Jul 2021 13:43:53 +0200 Subject: [PATCH] Introduce OpenMW integration tests --- .../integration_tests/test_lua_api/player.lua | 63 +++++++++++++ .../integration_tests/test_lua_api/test.lua | 67 +++++++++++++ .../test_lua_api/test.omwscripts | 3 + .../test_lua_api/testing_util.lua | 93 +++++++++++++++++++ scripts/integration_tests.py | 87 +++++++++++++++++ 5 files changed, 313 insertions(+) create mode 100644 scripts/data/integration_tests/test_lua_api/player.lua create mode 100644 scripts/data/integration_tests/test_lua_api/test.lua create mode 100644 scripts/data/integration_tests/test_lua_api/test.omwscripts create mode 100644 scripts/data/integration_tests/test_lua_api/testing_util.lua create mode 100755 scripts/integration_tests.py diff --git a/scripts/data/integration_tests/test_lua_api/player.lua b/scripts/data/integration_tests/test_lua_api/player.lua new file mode 100644 index 0000000000..c46ab5c46e --- /dev/null +++ b/scripts/data/integration_tests/test_lua_api/player.lua @@ -0,0 +1,63 @@ +local testing = require('testing_util') +local self = require('openmw.self') +local util = require('openmw.util') +local core = require('openmw.core') +local input = require('openmw.input') +local types = require('openmw.types') + +input.setControlSwitch(input.CONTROL_SWITCH.Fighting, false) +input.setControlSwitch(input.CONTROL_SWITCH.Jumping, false) +input.setControlSwitch(input.CONTROL_SWITCH.Looking, false) +input.setControlSwitch(input.CONTROL_SWITCH.Magic, false) +input.setControlSwitch(input.CONTROL_SWITCH.VanityMode, false) +input.setControlSwitch(input.CONTROL_SWITCH.ViewMode, false) + +testing.registerLocalTest('playerMovement', + function() + local startTime = core.getSimulationTime() + local pos = self.position + + while core.getSimulationTime() < startTime + 0.5 do + self.controls.jump = false + self.controls.run = true + self.controls.movement = 0 + self.controls.sideMovement = 0 + local progress = (core.getSimulationTime() - startTime) / 0.5 + self.controls.yawChange = util.normalizeAngle(math.rad(90) * progress - self.rotation.z) + coroutine.yield() + end + testing.expectEqualWithDelta(self.rotation.z, math.rad(90), 0.05, 'Incorrect rotation') + + while core.getSimulationTime() < startTime + 1.5 do + self.controls.jump = false + self.controls.run = true + self.controls.movement = 1 + self.controls.sideMovement = 0 + self.controls.yawChange = 0 + coroutine.yield() + end + direction = (self.position - pos) / types.Actor.runSpeed(self) + testing.expectEqualWithDelta(direction.x, 1, 0.1, 'Run forward, X coord') + testing.expectEqualWithDelta(direction.y, 0, 0.1, 'Run forward, Y coord') + + pos = self.position + while core.getSimulationTime() < startTime + 2.5 do + self.controls.jump = false + self.controls.run = false + self.controls.movement = -1 + self.controls.sideMovement = -1 + self.controls.yawChange = 0 + coroutine.yield() + end + direction = (self.position - pos) / types.Actor.walkSpeed(self) + testing.expectEqualWithDelta(direction.x, -0.707, 0.1, 'Walk diagonally, X coord') + testing.expectEqualWithDelta(direction.y, 0.707, 0.1, 'Walk diagonally, Y coord') + end) + +return { + engineHandlers = { + onUpdate = testing.updateLocal, + }, + eventHandlers = testing.eventHandlers +} + diff --git a/scripts/data/integration_tests/test_lua_api/test.lua b/scripts/data/integration_tests/test_lua_api/test.lua new file mode 100644 index 0000000000..573844199b --- /dev/null +++ b/scripts/data/integration_tests/test_lua_api/test.lua @@ -0,0 +1,67 @@ +local testing = require('testing_util') +local core = require('openmw.core') +local async = require('openmw.async') +local util = require('openmw.util') + +local function testTimers() + testing.expectAlmostEqual(core.getGameTimeScale(), 30, 'incorrect getGameTimeScale() result') + testing.expectAlmostEqual(core.getSimulationTimeScale(), 1, 'incorrect getSimulationTimeScale result') + + local startGameTime = core.getGameTime() + local startSimulationTime = core.getSimulationTime() + + local ts1, ts2, th1, th2 + local cb = async:registerTimerCallback("tfunc", function(arg) + if arg == 'g' then + th1 = core.getGameTime() - startGameTime + else + ts1 = core.getSimulationTime() - startSimulationTime + end + end) + async:newGameTimer(36, cb, 'g') + async:newSimulationTimer(0.5, cb, 's') + async:newUnsavableGameTimer(72, function() + th2 = core.getGameTime() - startGameTime + end) + async:newUnsavableSimulationTimer(1, function() + ts2 = core.getSimulationTime() - startSimulationTime + end) + + while not (ts1 and ts2 and th1 and th2) do coroutine.yield() end + + testing.expectAlmostEqual(th1, 36, 'async:newGameTimer failed') + testing.expectAlmostEqual(ts1, 0.5, 'async:newSimulationTimer failed') + testing.expectAlmostEqual(th2, 72, 'async:newUnsavableGameTimer failed') + testing.expectAlmostEqual(ts2, 1, 'async:newUnsavableSimulationTimer failed') +end + +local function testTeleport() + player:teleport('', util.vector3(100, 50, 0), util.vector3(0, 0, math.rad(-90))) + coroutine.yield() + testing.expect(player.cell.isExterior, 'teleport to exterior failed') + testing.expectEqualWithDelta(player.position.x, 100, 1, 'incorrect position after teleporting') + testing.expectEqualWithDelta(player.position.y, 50, 1, 'incorrect position after teleporting') + testing.expectEqualWithDelta(player.rotation.z, math.rad(-90), 0.05, 'incorrect rotation after teleporting') + + player:teleport('', util.vector3(50, -100, 0)) + coroutine.yield() + testing.expect(player.cell.isExterior, 'teleport to exterior failed') + testing.expectEqualWithDelta(player.position.x, 50, 1, 'incorrect position after teleporting') + testing.expectEqualWithDelta(player.position.y, -100, 1, 'incorrect position after teleporting') + testing.expectEqualWithDelta(player.rotation.z, math.rad(-90), 0.05, 'teleporting changes rotation') +end + +tests = { + {'timers', testTimers}, + {'playerMovement', function() testing.runLocalTest(player, 'playerMovement') end}, + {'teleport', testTeleport}, +} + +return { + engineHandlers = { + onUpdate = testing.testRunner(tests), + onPlayerAdded = function(p) player = p end, + }, + eventHandlers = testing.eventHandlers, +} + diff --git a/scripts/data/integration_tests/test_lua_api/test.omwscripts b/scripts/data/integration_tests/test_lua_api/test.omwscripts new file mode 100644 index 0000000000..97f523afbd --- /dev/null +++ b/scripts/data/integration_tests/test_lua_api/test.omwscripts @@ -0,0 +1,3 @@ +GLOBAL: test.lua +PLAYER: player.lua + diff --git a/scripts/data/integration_tests/test_lua_api/testing_util.lua b/scripts/data/integration_tests/test_lua_api/testing_util.lua new file mode 100644 index 0000000000..719502e741 --- /dev/null +++ b/scripts/data/integration_tests/test_lua_api/testing_util.lua @@ -0,0 +1,93 @@ +local core = require('openmw.core') + +local M = {} +local currentLocalTest = nil +local currentLocalTestError = nil + +function M.testRunner(tests) + local fn = function() + for i, test in ipairs(tests) do + local name, fn = unpack(test) + print('TEST_START', i, name) + local status, err = pcall(fn) + if status then + print('TEST_OK', i, name) + else + print('TEST_FAILED', i, name, err) + end + end + core.quit() + end + local co = coroutine.create(fn) + return function() + if coroutine.status(co) ~= 'dead' then + coroutine.resume(co) + end + end +end + +function M.runLocalTest(obj, name) + currentLocalTest = name + currentLocalTestError = nil + obj:sendEvent('runLocalTest', name) + while currentLocalTest do coroutine.yield() end + if currentLocalTestError then error(currentLocalTestError, 2) end +end + +function M.expect(cond, delta, msg) + if not cond then + error(msg or '"true" expected', 2) + end +end + +function M.expectEqualWithDelta(v1, v2, delta, msg) + if math.abs(v1 - v2) > delta then + error(string.format('%s: %f ~= %f', msg or '', v1, v2), 2) + end +end + +function M.expectAlmostEqual(v1, v2, msg) + if math.abs(v1 - v2) / (math.abs(v1) + math.abs(v2)) > 0.05 then + error(string.format('%s: %f ~= %f', msg or '', v1, v2), 2) + end +end + +local localTests = {} +local localTestRunner = nil + +function M.registerLocalTest(name, fn) + localTests[name] = fn +end + +function M.updateLocal() + if localTestRunner and coroutine.status(localTestRunner) ~= 'dead' then + coroutine.resume(localTestRunner) + else + localTestRunner = nil + end +end + +M.eventHandlers = { + runLocalTest = function(name) -- used only in local scripts + fn = localTests[name] + if not fn then + core.sendGlobalEvent('localTestFinished', {name=name, errMsg='Test not found'}) + return + end + localTestRunner = coroutine.create(function() + local status, err = pcall(fn) + if status then err = nil end + core.sendGlobalEvent('localTestFinished', {name=name, errMsg=err}) + end) + end, + localTestFinished = function(data) -- used only in global scripts + if data.name ~= currentLocalTest then + error(string.format('localTestFinished with incorrect name %s, expected %s', data.name, currentLocalTest)) + end + currentLocalTest = nil + currentLocalTestError = data.errMsg + end, +} + +return M + diff --git a/scripts/integration_tests.py b/scripts/integration_tests.py new file mode 100755 index 0000000000..b125e688fb --- /dev/null +++ b/scripts/integration_tests.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 + +import argparse, datetime, os, subprocess, sys, shutil +from pathlib import Path + +parser = argparse.ArgumentParser(description="OpenMW integration tests.") +parser.add_argument( + "example_suite", + type=str, + help="path to openmw example suite (use 'git clone https://gitlab.com/OpenMW/example-suite/' to get it)", +) +parser.add_argument("--omw", type=str, default="openmw", help="path to openmw binary") +parser.add_argument( + "--workdir", type=str, default="integration_tests_output", help="directory for temporary files and logs" +) +args = parser.parse_args() + +example_suite_dir = Path(args.example_suite).resolve() +example_suite_content = example_suite_dir / "game_template" / "data" / "template.omwgame" +if not example_suite_content.is_file(): + sys.exit( + f"{example_suite_content} not found, use 'git clone https://gitlab.com/OpenMW/example-suite/' to get it" + ) + +openmw_binary = Path(args.omw).resolve() +if not openmw_binary.is_file(): + sys.exit(f"{openmw_binary} not found") + +work_dir = Path(args.workdir).resolve() +work_dir.mkdir(parents=True, exist_ok=True) +config_dir = work_dir / "config" +userdata_dir = work_dir / "userdata" +tests_dir = Path(__file__).resolve().parent / "data" / "integration_tests" +time_str = datetime.datetime.now().strftime("%Y-%m-%d-%H.%M.%S") + + +def runTest(name): + print(f"Start {name}") + shutil.rmtree(config_dir, ignore_errors=True) + config_dir.mkdir() + shutil.copyfile(example_suite_dir / "settings.cfg", config_dir / "settings.cfg") + test_dir = tests_dir / name + with open(config_dir / "openmw.cfg", "w", encoding="utf-8") as omw_cfg: + omw_cfg.writelines( + ( + f'data="{example_suite_dir}{os.sep}game_template{os.sep}data"\n', + f'data-local="{test_dir}"\n', + f'user-data="{userdata_dir}"\n', + "content=template.omwgame\n", + ) + ) + if (test_dir / "test.omwscripts").exists(): + omw_cfg.write("content=test.omwscripts\n") + with subprocess.Popen( + [f"{openmw_binary}", "--replace=config", f"--config={config_dir}", "--skip-menu", "--no-grab"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + encoding="utf-8", + ) as process: + quit_requested = False + for line in process.stdout: + words = line.split(" ") + if len(words) > 1 and words[1] == "E]": + print(line, end="") + elif "Quit requested by a Lua script" in line: + quit_requested = True + elif "TEST_START" in line: + w = line.split("TEST_START")[1].split("\t") + print(f"TEST {w[2].strip()}\t\t", end="") + elif "TEST_OK" in line: + print(f"OK") + elif "TEST_FAILED" in line: + w = line.split("TEST_FAILED")[1].split("\t") + print(f"FAILED {w[3]}\t\t") + process.wait(5) + if not quit_requested: + print("ERROR: Unexpected termination") + shutil.copyfile(config_dir / "openmw.log", work_dir / f"{name}.{time_str}.log") + print(f"{name} finished") + + +for entry in tests_dir.glob("test_*"): + if entry.is_dir(): + runTest(entry.name) +shutil.rmtree(config_dir, ignore_errors=True) +shutil.rmtree(userdata_dir, ignore_errors=True) +