#! /usr/bin/python3 -B # # SPDX-License-Identifier: BSD-2-Clause # # Copyright (c) 2018-2021 Gavin D. Howard and contributors. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright notice, this # list of conditions and the following disclaimer. # # * Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # import os, errno import random import sys import subprocess # I want line length to *not* affect differences between the two, so I set it # as high as possible. env = { "BC_LINE_LENGTH": "65535", "DC_LINE_LENGTH": "65535" } # Generate a random integer between 0 and 2^limit. # @param limit The power of two for the upper limit. def gen(limit=4): return random.randint(0, 2 ** (8 * limit)) # Returns a random boolean for whether a number should be negative or not. def negative(): return random.randint(0, 1) == 1 # Returns a random boolean for whether a number should be 0 or not. I decided to # have it be 0 every 2^4 times since sometimes it is used to make a number less # than 1. def zero(): return random.randint(0, 2 ** (4) - 1) == 0 # Generate a real portion of a number. def gen_real(): # Figure out if we should have a real portion. If so generate it. if negative(): n = str(gen(25)) length = gen(7 / 8) if len(n) < length: n = ("0" * (length - len(n))) + n else: n = "0" return n # Generates a number (as a string) based on the parameters. # @param op The operation under test. # @param neg Whether the number can be negative. # @param real Whether the number can be a non-integer. # @param z Whether the number can be zero. # @param limit The power of 2 upper limit for the number. def num(op, neg, real, z, limit=4): # Handle zero first. if z: z = zero() else: z = False if z: # Generate a real portion maybe if real: n = gen_real() if n != "0": return "0." + n return "0" # Figure out if we should be negative. if neg: neg = negative() # Generate the integer portion. g = gen(limit) # Figure out if we should have a real number. negative() is used to give a # 50/50 chance of getting a negative number. if real: n = gen_real() else: n = "0" # Generate the string. g = str(g) if n != "0": g = g + "." + n # Make sure to use the right negative sign. if neg and g != "0": if op != modexp: g = "-" + g else: g = "_" + g return g # Add a failed test to the list. # @param test The test that failed. # @param op The operation for the test. def add(test, op): tests.append(test) gen_ops.append(op) # Compare the output between the two. # @param exe The executable under test. # @param options The command-line options. # @param p The object returned from subprocess.run() for the calculator # under test. # @param test The test. # @param halt The halt string for the calculator under test. # @param expected The expected result. # @param op The operation under test. # @param do_add If true, add a failing test to the list, otherwise, don't. def compare(exe, options, p, test, halt, expected, op, do_add=True): # Check for error from the calculator under test. if p.returncode != 0: print(" {} returned an error ({})".format(exe, p.returncode)) if do_add: print(" adding to checklist...") add(test, op) return actual = p.stdout.decode() # Check for a difference in output. if actual != expected: if op >= exponent: # This is here because GNU bc, like mine can be flaky on the # functions in the math library. This is basically testing if adding # 10 to the scale works to make them match. If so, the difference is # only because of that. indata = "scale += 10; {}; {}".format(test, halt) args = [ exe, options ] p2 = subprocess.run(args, input=indata.encode(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) expected = p2.stdout[:-10].decode() if actual == expected: print(" failed because of bug in other {}".format(exe)) print(" continuing...") return # Do the correct output for the situation. if do_add: print(" failed; adding to checklist...") add(test, op) else: print(" failed {}".format(test)) print(" expected:") print(" {}".format(expected)) print(" actual:") print(" {}".format(actual)) # Generates a test for op. I made sure that there was no clashing between # calculators. Each calculator is responsible for certain ops. # @param op The operation to test. def gen_test(op): # First, figure out how big the scale should be. scale = num(op, False, False, True, 5 / 8) # Do the right thing for each op. Generate the test based on the format # string and the constraints of each op. For example, some ops can't accept # 0 in some arguments, and some must have integers in some arguments. if op < div: s = fmts[op].format(scale, num(op, True, True, True), num(op, True, True, True)) elif op == div or op == mod: s = fmts[op].format(scale, num(op, True, True, True), num(op, True, True, False)) elif op == power: s = fmts[op].format(scale, num(op, True, True, True, 7 / 8), num(op, True, False, True, 6 / 8)) elif op == modexp: s = fmts[op].format(scale, num(op, True, False, True), num(op, True, False, True), num(op, True, False, False)) elif op == sqrt: s = "1" while s == "1": s = num(op, False, True, True, 1) s = fmts[op].format(scale, s) else: if op == exponent: first = num(op, True, True, True, 6 / 8) elif op == bessel: first = num(op, False, True, True, 6 / 8) else: first = num(op, True, True, True) if op != bessel: s = fmts[op].format(scale, first) else: s = fmts[op].format(scale, first, 6 / 8) return s # Runs a test with number t. # @param t The number of the test. def run_test(t): # Randomly select the operation. op = random.randrange(bessel + 1) # Select the right calculator. if op != modexp: exe = "bc" halt = "halt" options = "-lq" else: exe = "dc" halt = "q" options = "" # Generate the test. test = gen_test(op) # These don't work very well for some reason. if "c(0)" in test or "scale = 4; j(4" in test: return # Make sure the calculator will halt. bcexe = exedir + "/" + exe indata = test + "\n" + halt print("Test {}: {}".format(t, test)) # Only bc has options. if exe == "bc": args = [ exe, options ] else: args = [ exe ] # Run the GNU bc. p = subprocess.run(args, input=indata.encode(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) output1 = p.stdout.decode() # Error checking for GNU. if p.returncode != 0 or output1 == "": print(" other {} returned an error ({}); continuing...".format(exe, p.returncode)) return if output1 == "\n": print(" other {} has a bug; continuing...".format(exe)) return # Don't know why GNU has this problem... if output1 == "-0\n": output1 = "0\n" elif output1 == "-0": output1 = "0" args = [ bcexe, options ] # Run this bc/dc and compare. p = subprocess.run(args, input=indata.encode(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) compare(exe, options, p, test, halt, output1, op) # This script must be run by itself. if __name__ != "__main__": sys.exit(1) script = sys.argv[0] testdir = os.path.dirname(script) exedir = testdir + "/../bin" # The following are tables used to generate numbers. # The operations to test. ops = [ '+', '-', '*', '/', '%', '^', '|' ] # The functions that can be tested. funcs = [ "sqrt", "e", "l", "a", "s", "c", "j" ] # The files (corresponding to the operations with the functions appended) to add # tests to if they fail. files = [ "add", "subtract", "multiply", "divide", "modulus", "power", "modexp", "sqrt", "exponent", "log", "arctangent", "sine", "cosine", "bessel" ] # The format strings corresponding to each operation and then each function. fmts = [ "scale = {}; {} + {}", "scale = {}; {} - {}", "scale = {}; {} * {}", "scale = {}; {} / {}", "scale = {}; {} % {}", "scale = {}; {} ^ {}", "{}k {} {} {}|pR", "scale = {}; sqrt({})", "scale = {}; e({})", "scale = {}; l({})", "scale = {}; a({})", "scale = {}; s({})", "scale = {}; c({})", "scale = {}; j({}, {})" ] # Constants to make some code easier later. div = 3 mod = 4 power = 5 modexp = 6 sqrt = 7 exponent = 8 bessel = 13 gen_ops = [] tests = [] # Infinite loop until the user sends SIGINT. try: i = 0 while True: run_test(i) i = i + 1 except KeyboardInterrupt: pass # This is where we start processing the checklist of possible failures. Why only # possible failures? Because some operations, specifically the functions in the # math library, are not guaranteed to be exactly correct. Because of that, we # need to present every failed test to the user for a final check before we # add them as test cases. # No items, just exit. if len(tests) == 0: print("\nNo items in checklist.") print("Exiting") sys.exit(0) print("\nGoing through the checklist...\n") # Just do some error checking. If this fails here, it's a bug in this script. if len(tests) != len(gen_ops): print("Corrupted checklist!") print("Exiting...") sys.exit(1) # Go through each item in the checklist. for i in range(0, len(tests)): # Yes, there's some code duplication. Sue me. print("\n{}".format(tests[i])) op = int(gen_ops[i]) if op != modexp: exe = "bc" halt = "halt" options = "-lq" else: exe = "dc" halt = "q" options = "" # We want to run the test again to show the user the difference. indata = tests[i] + "\n" + halt args = [ exe, options ] p = subprocess.run(args, input=indata.encode(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) expected = p.stdout.decode() bcexe = exedir + "/" + exe args = [ bcexe, options ] p = subprocess.run(args, input=indata.encode(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) compare(exe, options, p, tests[i], halt, expected, op, False) # Ask the user to make a decision on the failed test. answer = input("\nAdd test ({}/{}) to test suite? [y/N]: ".format(i + 1, len(tests))) # Quick and dirty answer parsing. if 'Y' in answer or 'y' in answer: print("Yes") name = testdir + "/" + exe + "/" + files[op] # Write the test to the test file and the expected result to the # results file. with open(name + ".txt", "a") as f: f.write(tests[i] + "\n") with open(name + "_results.txt", "a") as f: f.write(expected) else: print("No") print("Done!")