Core commit. Compile and run on the OpenPandora
[mupen64plus-pandora.git] / source / mupen64plus-core / tools / regtests / regression-video.py
diff --git a/source/mupen64plus-core/tools/regtests/regression-video.py b/source/mupen64plus-core/tools/regtests/regression-video.py
new file mode 100755 (executable)
index 0000000..1d438b7
--- /dev/null
@@ -0,0 +1,527 @@
+#!/usr/bin/env python
+
+#/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+# *   Mupen64plus - regression-video.py                                     *
+# *   Mupen64Plus homepage: http://code.google.com/p/mupen64plus/           *
+# *   Copyright (C) 2008-2012 Richard Goedeken                              *
+# *                                                                         *
+# *   This program is free software; you can redistribute it and/or modify  *
+# *   it under the terms of the GNU General Public License as published by  *
+# *   the Free Software Foundation; either version 2 of the License, or     *
+# *   (at your option) any later version.                                   *
+# *                                                                         *
+# *   This program is distributed in the hope that it will be useful,       *
+# *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
+# *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
+# *   GNU General Public License for more details.                          *
+# *                                                                         *
+# *   You should have received a copy of the GNU General Public License     *
+# *   along with this program; if not, write to the                         *
+# *   Free Software Foundation, Inc.,                                       *
+# *   51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.          *
+# * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
+
+from optparse import OptionParser
+from threading import Thread
+from datetime import date
+import subprocess
+import commands
+import shutil
+import stat
+import sys
+import os
+
+# set global report string
+report = "Mupen64Plus Regression Test report\n----------------------------------\n"
+
+#******************************************************************************
+# main functions
+#
+
+def main(rootdir, cfgfile, nobuild, noemail):
+    global report
+    # set up child directory paths
+    srcdir = os.path.join(rootdir, "source")
+    shotdir = os.path.join(rootdir, "current")
+    refdir = os.path.join(rootdir, "reference")
+    archivedir = os.path.join(rootdir, "archive")
+    # run the test procedure
+    tester = RegTester(rootdir, srcdir, shotdir)
+    rval = 0
+    while True:
+        # Step 1: load the test config file
+        if not tester.LoadConfig(cfgfile):
+            rval = 1
+            break
+        # Step 2: check out from Mercurial
+        if not nobuild:
+            if not tester.CheckoutSource(srcdir):
+                rval = 2
+                break
+        # Step 3: run test builds
+        if not nobuild:
+            for modname in tester.modulesAndParams:
+                module = tester.modulesAndParams[modname]
+                if "testbuilds" not in module:
+                    continue
+                modurl = module["url"]
+                modfilename = modurl.split('/')[-1]
+                testlist = [ name.strip() for name in module["testbuilds"].split(',') ]
+                makeparams = [ params.strip() for params in module["testbuildparams"].split(',') ]
+                if len(testlist) != len(makeparams):
+                    report += "Config file error for test builds in %s.  Build name list and makefile parameter list have different lengths.\n" % modname
+                testbuilds = min(len(testlist), len(makeparams))
+                for i in range(testbuilds):
+                    buildname = testlist[i]
+                    buildmake = makeparams[i]
+                    BuildSource(srcdir, modfilename, modname, buildname, buildmake, module["outputfiles"], True)
+        # Step 4: build the binary for the video regression test
+        if not nobuild:
+            for modname in tester.modulesAndParams:
+                module = tester.modulesAndParams[modname]
+                modurl = module["url"]
+                modfilename = modurl.split('/')[-1]
+                videobuild = module["videobuild"]
+                videomake = module["videobuildparams"]
+                if not BuildSource(srcdir, modfilename, modname, videobuild, videomake, module["outputfiles"], False):
+                    rval = 3
+                    break
+            if rval != 0:
+                break
+        # Step 5: run the tests, check the results
+        if not tester.RunTests():
+            rval = 4
+            break
+        if not tester.CheckResults(refdir):
+            rval = 5
+            break
+        # test procedure is finished
+        break
+    # Step 6: send email report and archive the results
+    if not noemail:
+       if not tester.SendReport():
+               rval = 6
+    if not tester.ArchiveResults(archivedir):
+        rval = 7
+    # all done with test process
+    return rval
+
+#******************************************************************************
+# Checkout & build functions
+#
+
+def BuildSource(srcdir, moddir, modname, buildname, buildmake, outputfiles, istest):
+    global report
+    makepath = os.path.join(srcdir, moddir,  "projects", "unix")
+    # print build report message and clear counters
+    testbuildcommand = "make -C %s %s" % (makepath, buildmake)
+    if istest:
+        report += "Running %s test build \"%s\"\n" % (modname, buildname)
+    else:
+        report += "Building %s \"%s\" for video test\n" % (modname, buildname)
+    warnings = 0
+    errors = 0
+    # run make and capture the output
+    output = commands.getoutput(testbuildcommand)
+    makelines = output.split("\n")
+    # print warnings and errors
+    for line in makelines:
+        if "error:" in line:
+            report += "    " + line + "\n"
+            errors += 1
+        if "warning:" in line:
+            report += "    " + line + "\n"
+            warnings += 1
+    report += "%i errors. %i warnings.\n" % (errors, warnings)
+    if errors > 0 and not istest:
+        return False
+    # check for output files
+    for filename in outputfiles.split(','):
+        if not os.path.exists(os.path.join(makepath, filename)):
+            report += "Build failed: '%s' not found\n" % filename
+            errors += 1
+    if errors > 0 and not istest:
+        return False
+    # clean up if this was a test
+    if istest:
+        os.system("make -C %s clean" % makepath)
+    # if this wasn't a test, then copy our output files and data files
+    if not istest:
+        for filename in outputfiles.split(','):
+            shutil.move(os.path.join(makepath, filename), srcdir)
+        datapath = os.path.join(srcdir, moddir, "data")
+        if os.path.isdir(datapath):
+            copytree(datapath, os.path.join(srcdir, "data"))
+    # build was successful!
+    return True
+
+#******************************************************************************
+# Test execution classes
+#
+class RegTester:
+    def __init__(self, rootdir, bindir, screenshotdir):
+        self.rootdir = rootdir
+        self.bindir = bindir
+        self.screenshotdir = screenshotdir
+        self.generalParams = { }
+        self.gamesAndParams = { }
+        self.modulesAndParams = { }
+        self.videoplugins = [ "mupen64plus-video-rice.so" ]
+        self.thisdate = str(date.today())
+
+    def LoadConfig(self, filename):
+        global report
+        # read the config file
+        report += "\nLoading regression test configuration.\n"
+        try:
+            cfgfile = open(os.path.join(self.rootdir, filename), "r")
+            cfglines = cfgfile.read().split("\n")
+            cfgfile.close()
+        except Exception, e:
+            report += "Error in RegTestConfigParser::LoadConfig(): %s" % e
+            return False
+        # parse the file
+        GameFilename = None
+        ModuleName = None
+        for line in cfglines:
+            # strip leading and trailing whitespace
+            line = line.strip()
+            # test for comment
+            if len(line) == 0 or line[0] == '#':
+                continue
+            # test for new game filename
+            if line[0] == '[' and line [-1] == ']':
+                GameFilename = line[1:-1]
+                if GameFilename in self.gamesAndParams:
+                    report += "    Warning: Config file '%s' contains duplicate game entry '%s'\n" % (filename, GameFilename)
+                else:
+                    self.gamesAndParams[GameFilename] = { }
+                continue
+            # test for new source module build
+            if line[0] == '{' and line [-1] == '}':
+                ModuleName = line[1:-1]
+                if ModuleName in self.modulesAndParams:
+                    report += "    Warning: Config file '%s' contains duplicate source module '%s'\n" % (filename, ModuleName)
+                else:
+                    self.modulesAndParams[ModuleName] = { }
+                continue
+            # print warning and continue if it's not a (key = value) pair
+            pivot = line.find('=')
+            if pivot == -1:
+                report += "    Warning: Config file '%s' contains unrecognized line: '%s'\n" % (filename, line)
+                continue
+            # parse key, value
+            key = line[:pivot].strip().lower()
+            value = line[pivot+1:].strip()
+            if ModuleName is None:
+                paramDict = self.generalParams
+            elif GameFilename is None:
+                paramDict = self.modulesAndParams[ModuleName]
+            else:
+                paramDict = self.gamesAndParams[GameFilename]
+            if key in paramDict:
+                report += "    Warning: duplicate key '%s'\n" % key
+                continue
+            paramDict[key] = value
+        # check for required parameters
+        if "rompath" not in self.generalParams:
+            report += "    Error: rompath is not given in config file\n"
+            return False
+        # config is loaded
+        return True
+
+    def CheckoutSource(self, srcdir):
+        global report
+        # remove any current source directory
+        if not deltree(srcdir):
+            return False
+        os.mkdir(srcdir)
+        os.mkdir(os.path.join(srcdir, "data"))
+        # loop through all of the source modules
+        for modname in self.modulesAndParams:
+            module = self.modulesAndParams[modname]
+            if "url" not in module:
+                report += "Error: no Hg repository URL for module %s\n\n" % modname
+                return False
+            modurl = module["url"]
+            modfilename = modurl.split("/")[-1]
+            # call Hg to checkout Mupen64Plus source module
+            output = commands.getoutput("hg clone --cwd %s %s" % (srcdir, modurl))
+            # parse the output
+            lastline = output.split("\n")[-1]
+            if "0 files unresolved" not in lastline:
+                report += "Hg Error: %s\n\n" % lastline
+                return False
+            # get the revision info
+            RevFound = False
+            output = commands.getoutput("hg tip -R %s" % os.path.join(srcdir, modfilename))
+            for line in output.split('\n'):
+                words = line.split()
+                if len(words) == 2 and words[0] == 'changeset:':
+                    report += "Hg Checkout %s: changeset %s\n" % (modfilename, words[1])
+                    RevFound = True
+            if not RevFound:
+                report += "Hg Error: couldn't find revision information\n\n"
+                return False
+        return True
+
+    def RunTests(self):
+        global report
+        rompath = self.generalParams["rompath"]
+        if not os.path.exists(rompath):
+            report += "    Error: ROM directory '%s' does not exist!\n" % rompath
+            return False
+        # Remove any current screenshot directory
+        if not deltree(self.screenshotdir):
+            return False
+        # Data initialization and start message
+        os.mkdir(self.screenshotdir)
+        for plugin in self.videoplugins:
+            videoname = plugin[:plugin.find('.')]
+            os.mkdir(os.path.join(self.screenshotdir, videoname))
+        report += "\nRunning regression tests on %i games.\n" % len(self.gamesAndParams)
+        # loop over each game filename given in regtest config file
+        for GameFilename in self.gamesAndParams:
+            GameParams = self.gamesAndParams[GameFilename]
+            # if no screenshots parameter given for this game then skip it
+            if "screenshots" not in GameParams:
+                report += "    Warning: no screenshots taken for game '%s'\n" % GameFilename
+                continue
+            # make a list of screenshots and check it
+            shotlist = [ str(int(framenum.strip())) for framenum in GameParams["screenshots"].split(',') ]
+            if len(shotlist) < 1 or (len(shotlist) == 1 and shotlist[0] == '0'):
+                report += "    Warning: invalid screenshot list for game '%s'\n" % GameFilename
+                continue
+            # run a test for each video plugin
+            for plugin in self.videoplugins:
+                videoname = plugin[:plugin.find('.')]
+                # check if this plugin should be skipped
+                if "skipvideo" in GameParams:
+                    skipit = False
+                    skiplist = [ name.strip() for name in GameParams["skipvideo"].split(',') ]
+                    for skiptag in skiplist:
+                        if skiptag.lower() in plugin.lower():
+                            skipit = True
+                    if skipit:
+                        continue
+                # construct the command line
+                exepath = os.path.join(self.bindir, "mupen64plus")
+                exeparms = [ "--corelib", os.path.join(self.bindir, "libmupen64plus.so.2") ]
+                exeparms += [ "--testshots",  ",".join(shotlist) ]
+                exeparms += [ "--sshotdir", os.path.join(self.screenshotdir, videoname) ]
+                exeparms += [ "--plugindir", self.bindir ]
+                exeparms += [ "--datadir", os.path.join(self.bindir, "data") ]
+                myconfig = os.path.join(self.rootdir, "config")
+                exeparms += [ "--configdir", myconfig ]
+                exeparms += [ "--gfx", plugin ]
+                exeparms += [ "--emumode", "2" ]
+                exeparms += [ os.path.join(rompath, GameFilename) ]
+                # run it, but if it takes too long print an error and kill it
+                testrun = RegTestRunner(exepath, exeparms)
+                testrun.start()
+                testrun.join(60.0)
+                if testrun.isAlive():
+                    report += "    Error: Test run timed out after 60 seconds:  '%s'\n" % " ".join(exeparms)
+                    os.kill(testrun.pid, 9)
+                    testrun.join(10.0)
+                
+        # all tests have been run
+        return True                
+
+    def CheckResults(self, refdir):
+        global report
+        # print message
+        warnings = 0
+        errors = 0
+        report += "\nChecking regression test results\n"
+        # get lists of files in the reference folders
+        refshots = { }
+        if not os.path.exists(refdir):
+            os.mkdir(refdir)
+        for plugin in self.videoplugins:
+            videoname = plugin[:plugin.find('.')]
+            videodir = os.path.join(refdir, videoname)
+            if not os.path.exists(videodir):
+                os.mkdir(videodir)
+                refshots[videoname] = [ ]
+            else:
+                refshots[videoname] = [ filename for filename in os.listdir(videodir) ]
+        # get lists of files produced by current test runs
+        newshots = { }
+        for plugin in self.videoplugins:
+            videoname = plugin[:plugin.find('.')]
+            videodir = os.path.join(self.screenshotdir, videoname)
+            if not os.path.exists(videodir):
+                newshots[videoname] = [ ]
+            else:
+                newshots[videoname] = [ filename for filename in os.listdir(videodir) ]
+        # make list of matching ref/test screenshots, and look for missing reference screenshots
+        checklist = { }
+        for plugin in self.videoplugins:
+            videoname = plugin[:plugin.find('.')]
+            checklist[videoname] = [ ]
+            for filename in newshots[videoname]:
+                if filename in refshots[videoname]:
+                    checklist[videoname] += [ filename ]
+                else:
+                    report += "    Warning: reference screenshot '%s/%s' missing. Copying from current test run\n" % (videoname, filename)
+                    shutil.copy(os.path.join(self.screenshotdir, videoname, filename), os.path.join(refdir, videoname))
+                    warnings += 1
+        # look for missing test screenshots
+        for plugin in self.videoplugins:
+            videoname = plugin[:plugin.find('.')]
+            for filename in refshots[videoname]:
+                if filename not in newshots[videoname]:
+                    report += "    Error: Test screenshot '%s/%s' missing.\n" % (videoname, filename)
+                    errors += 1
+        # do image comparisons
+        for plugin in self.videoplugins:
+            videoname = plugin[:plugin.find('.')]
+            for filename in checklist[videoname]:
+                refimage = os.path.join(refdir, videoname, filename)
+                testimage = os.path.join(self.screenshotdir, videoname, filename)
+                diffimage = os.path.join(self.screenshotdir, videoname, os.path.splitext(filename)[0] + "_DIFF.png")
+                cmd = ("/usr/bin/compare", "-metric", "PSNR", refimage, testimage, diffimage)
+                pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT).stdout
+                similarity = pipe.read().strip()
+                pipe.close()
+                try:
+                    db = float(similarity)
+                except:
+                    db = 0
+                if db > 60.0:
+                    os.unlink(diffimage)
+                else:
+                    report += "    Warning: test image '%s/%s' does not match reference.  PSNR = %s\n" % (videoname, filename, similarity)
+                    warnings += 1
+        # give report and return
+        report += "%i errors. %i warnings.\n" % (errors, warnings)
+        return True
+
+    def SendReport(self):
+        global report
+        # if there are no email addresses in the config file, then just we're done
+        if "sendemail" not in self.generalParams:
+            return True
+        if len(self.generalParams["sendemail"]) < 5:
+            return True
+        # construct the email message header
+        emailheader = "To: %s\n" % self.generalParams["sendemail"]
+        emailheader += "From: Mupen64Plus-Tester@fascination.homelinux.net\n"
+        emailheader += "Subject: %s Regression Test Results for Mupen64Plus\n" % self.thisdate
+        emailheader += "Reply-to: do-not-reply@fascination.homelinux.net\n"
+        emailheader += "Content-Type: text/plain; charset=UTF-8\n"
+        emailheader += "Content-Transfer-Encoding: 8bit\n\n"
+        # open a pipe to sendmail and dump our report
+        try:
+            pipe = subprocess.Popen(("/usr/sbin/sendmail", "-t"), stdin=subprocess.PIPE).stdin
+            pipe.write(emailheader)
+            pipe.write(report)
+            pipe.close()
+        except Exception, e:
+            report += "Exception encountered when calling sendmail: '%s'\n" % e
+            report += "Email header:\n%s\n" % emailheader
+            return False
+        return True
+
+    def ArchiveResults(self, archivedir):
+        global report
+        # create archive dir if it doesn't exist
+        if not os.path.exists(archivedir):
+            os.mkdir(archivedir)
+        # move the images into a subdirectory of 'archive' given by date
+        subdir = os.path.join(archivedir, self.thisdate)
+        if os.path.exists(subdir):
+            if not deltree(subdir):
+                return False
+        if os.path.exists(self.screenshotdir):
+            shutil.move(self.screenshotdir, subdir)
+        # copy the report into the archive directory
+        f = open(os.path.join(archivedir, "report_%s.txt" % self.thisdate), "w")
+        f.write(report)
+        f.close()
+        # archival is complete
+        return True
+
+
+class RegTestRunner(Thread):
+    def __init__(self, exepath, exeparms):
+        self.exepath = exepath
+        self.exeparms = exeparms
+        self.pid = 0
+        self.returnval = None
+        Thread.__init__(self)
+
+    def run(self):
+        # start the process
+        testprocess = subprocess.Popen([self.exepath] + self.exeparms)
+        # get the PID of the new test process
+        self.pid = testprocess.pid
+        # wait for the test to complete
+        self.returnval = testprocess.wait()
+
+
+#******************************************************************************
+# Generic helper functions
+#
+
+def deltree(dirname):
+    global report
+    if not os.path.exists(dirname):
+        return True
+    try:
+        for path in (os.path.join(dirname, filename) for filename in os.listdir(dirname)):
+            if os.path.isdir(path):
+                if not deltree(path):
+                    return False
+            else:
+                os.unlink(path)
+        os.rmdir(dirname)
+    except Exception, e:
+        report += "Error in deltree(): %s\n" % e
+        return False
+
+    return True
+
+def copytree(srcpath, dstpath):
+    if not os.path.isdir(srcpath) or not os.path.isdir(dstpath):
+        return False
+    for filename in os.listdir(srcpath):
+        filepath = os.path.join(srcpath, filename)
+        if os.path.isdir(filepath):
+            subdstpath = os.path.join(dstpath, filename)
+            os.mkdir(subdstpath)
+            copytree(filepath, subdstpath)
+        else:
+            shutil.copy(filepath, dstpath)
+    return True
+
+#******************************************************************************
+# main function call for standard script execution
+#
+
+if __name__ == "__main__":
+    # parse the command-line arguments
+    parser = OptionParser()
+    parser.add_option("-n", "--nobuild", dest="nobuild", default=False, action="store_true",
+                      help="Assume source code is present; don't check out and build")
+    parser.add_option("-e", "--noemail", dest="noemail", default=False, action="store_true",
+                      help="don't send email or archive results")
+    parser.add_option("-t", "--testpath", dest="testpath",
+                      help="Set root of testing directory to PATH", metavar="PATH")
+    parser.add_option("-c", "--cfgfile", dest="cfgfile", default="daily-tests.cfg",
+                      help="Use regression test config file FILE", metavar="FILE")
+    (opts, args) = parser.parse_args()
+    # check test path
+    if opts.testpath is None:
+        # change directory to the directory containing this script and set root test path to "."
+        scriptdir = os.path.dirname(sys.argv[0])
+        os.chdir(scriptdir)
+        rootdir = "."
+    else:
+        rootdir = opts.testpath
+    # call the main function
+    rval = main(rootdir, opts.cfgfile, opts.nobuild, opts.noemail)
+    sys.exit(rval)
+
+