blob: 896f0e46c97f9c6a0dfe5e46ff8be974de9758b3 [file] [log] [blame]
#! /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!")