四则运算手册

四则运算器手册

使用方法

运行UI.py文件或者在命令行使用

相对路径>python UI.py
or
>python (该程序所在的路径)

分别两个功能,点击就可以使用

操作错误也会给出提示

生成器

可以指定数量(必须指定-支持int的个数),可以设定范围(可以不指定)需要以a-b的形式给出

返回按钮可以返回主界面,注意点击右上角关闭会结束所有程序执行

选定参数后执行生成题目就会打印在窗口上,再点击显示答案可以看到每一道题答案

对比器

按照提示就可以使用了,请注意选择的文件一定要按照顺序(虽然选错也会有提示就是了)

代码实现

实现逻辑不重新用大纲排列了,直接写在代码中更好理解

不讨论UI的实现,UI实现很简单,调用库和算法逻辑就可以

生成器

随机数的生成
def generate_number(min_num, max_num):
    # 逻辑介绍,使用random的库来实现随机数的生成
    # 其中接受两个参数实现范围的限制
    # 这两个数应该传入小于10不等于01才合法
    # 返回的是一个字符串,这样有利于后面eval函数的操作(这里改了很久)
    # 首先第一个随机数指定生成分数还是整数--这使得代码更加可读
    rand_num = random.random()
    # 两者的概率应该相等
     if rand_num < 0.5:  # 生成整数的概率为50%
        return str(random.randint(min_num, max_num))
分数的处理

这部分很有意思,搜查了不少资料去更改假分数的情况

# 首先,我们通过随机数来生成分子与分母
    else:  # 生成分数的概率为50%
        numerator = random.randint(min_num, max_num)
        denominator = random.randint(min_num, max_num)
        fraction = Fraction(numerator, denominator)
        # 我们得到了一个由fractions处理过的分式形式a/b
        # 接下来我们来分开处理假分数与真分数
        # 简单说下假分数的处理,我们知道分子大于分母的时候可以使用
        # 整数’分数的形式来表示假分数
        # 那么这个整数怎么来的?
        # //运算符给了一种取整的可能
        # 这样我们得到了这个整数用whole_part表示吧
        # 分式呢?很简单,取余就可以,取余得到的数作为新的分子就好了
        # 这样我们就得到了一个假分数了
         if numerator > denominator:
            whole_part = numerator // denominator
            numerator = numerator % denominator
            if numerator == 0:  # 如果分子为0,只返回整数部分
                return str(whole_part)
            else:
                return f"({whole_part} + {Fraction(numerator, denominator)})"
        else:
            return str(fraction)
表达式的生成
def generate_exercises(num_exercises, min_num, max_num):
    # 三个参数(题目数量,下限,上限)
    # 怎么实现呢?我们之前得到了随机数生成,那么只需要生成两次不就好了
    # 首先想个办法来存结果,就用列表吧,list[]
    # 那么操作符呢?很简单在所有操作符里随机找一个就行
    exercises = []
    ops = ['+', '-', '*', '/']
        for _ in range(num_exercises):
        # 两次随机数调用
        num1 = generate_number(min_num, max_num)
        num2 = generate_number(min_num, max_num)
        # 这里用了random的choice随机旨在操作符列表选一个
        op = random.choice(ops)
        # 又一个问题,我们不想要负数怎么办?很简单,调换位置就OK
        if op == '-' and eval(num1) < eval(num2):
            num1, num2 = num2, num1
        # 这里不好解释,这是因为我在后面对表达式进行运算的时候发现
        # 假如有2 ÷ 1’5的时候给出的结果是7 !? 这不对
        # 调试了几次发现eval函数在进行str类型转换的时候这里变成了
        # 2 ÷ 1 + 5,这不对!
        # 我们需要先运行÷,怎么办呢,在生成这里我们加上括号
        # 对除法运算的右操作数添加括号
        # 我采用了在赋值就判断的语句,之前的多段elif太长了,影响可读性
        exercise = f"{num1} {op} {num2}" if op != '/' or "/" not in num2 else f"{num1} {op} ({num2})"
        # 然后list的append操作顺着加到空列表就得到了需要的str的list
        exercises.append(exercise)
        # 返回这个列表就结束了(本来返回的是单个str,性能太差了)
    return exercises
结果的写入
# 需求要求把表达式和答案都写进文件中
# 首先思考我们有什么,一个list列表,存着不少str
# 先打开目标文件,就用exercise放表达式,answer放结果吧
# 用了with as语句,这个语句对比普通的open简直很好,他会自动释放对象
# 这样我们就不用来一遍close了
with open('exercise.txt', 'w') as exer_file, open('answer.txt', 'w') as ans_file:
    # 我们有什么?一个str的列表
    # 怎么办?我们来遍历这个列表
    for i, exercise in enumerate(exercises, 1):
        # 这有一个问题,我们的表达式是有÷的
        # python识别不了,eval函数处理不了,怎么办?
        # list有一个replace操作很方便
        answer = eval(exercise.replace('÷', '/'))
        # 我们要计算了,但是出错了,为什么?
        # 我们的假分数还是a’b的形式,python还是不会用它
        # 参考之前的转换,逆向操作一下
            if answer % 1 != 0:
                # 这里用到了fractions模块的limit_denominator(),这个东西是干嘛的?
                # 就是把一个浮点数转换为最近的分数形式,具体实现网上能找到或者可以阅读源代码
                answer = Fraction(answer).limit_denominator()
            else:
                # 为什么要在整数的时候加上int转换?
                # 防止其进入接下来的if语句,不然会出现3’0的情况(调试了好久)
                answer = int(answer)
            if isinstance(answer, Fraction) and answer.numerator > answer.denominator:
                # 假分数的格式转换,之前说过了
                whole_part = answer.numerator // answer.denominator
                numerator = answer.numerator % answer.denominator
                answer = f"{whole_part}’{Fraction(numerator, answer.denominator)}"
            # 使用正则表达式替换只作为运算符的除号
            # 为什么要替换回来?因为接下来要写入文件了嘛
            # 换了两次(第一次忘记换假分数了,懒得修改了)
            # 使用re的sub替换,通过正则表达式来匹配(?<= )/(?= )
            # 下面假分数替换也是\((\d+) \+ (\d+/\d+)\),具体逻辑上网查查
            exercise = re.sub(r'(?<= )/(?= )', '÷', exercise)
            # 将假分数的形式转换回 a'b/c
            exercise = re.sub(r"\((\d+) \+ (\d+/\d+)\)", r"\1’\2", exercise)
            # 使用write来写入这里用了i(循环参数)来对应题目序号,很方便
            exer_file.write(f"{i}. {exercise} = \n")
            ans_file.write(f"{i}. {answer}\n")

这就是生成器每一步的实现了

对比器

对比器的难度更大一些,在处理传入文本有些棘手,并且传入文本如果不是按生成器格式生成的就没办法识别,想不到更好的办法优化,但是在处理假分数套用生成器的就可以,所以实现起来也不算慢

def compare_answers(exercise_file, answer_file, output_file):
    # 三个参数,题目文件,答案文件,和输出文件三个的路径
    # 为配合图形化界面的提示操作,使用了try来开头
    # 这样的好处很简单,在读取空路径或错误文件的时候直接抛出错误传递参数待图形化处理就好,免去异常处理操作
    try:
        with open(exercise_file, 'r') as f_ex, open(answer_file, 'r') as f_ans:
            exercises = f_ex.readlines()
            answers = f_ans.readlines()
    except FileNotFoundError:
        return "FileNotFoundError"
    
    # 我们的结果存在哪里?我还是选择了list,方便加入和遍历
    correct = []
    incorrect = []
    # 现在我们有什么?四个list,两个是读取文件产生的list即exercises和answers
    # 很容易想到,可以遍历表达式来获得正确答案再和有的答案对比
    # 这里要解释一下zip和enumerate
    # zip可以将exercises和answers中的元素按一个个元组返回
    # enumerate则会以迭代器的形式从1返回zip的元组,简单说就是题目序号加上元组不,目的就是每一组配对起来
    for i, (exercise, answer) in enumerate(zip(exercises, answers), 1):
        try:
            # 移除序号和等号,将题目转换为一个有效的表达式
            # 用了split切割开序号和等号,然后替换÷为合法/
            exercise = exercise.split('=')[0].split('. ')[1].strip().replace('÷', '/')
            # 将表达式中的每个操作数都转换为普通分数的形式
            # 这里调用了函数convert_mixed_fraction_in_expression来对表达式合法化
            exercise = convert_mixed_fraction_in_expression(exercise)
            # 计算题目的正确答案
            # 之前的合法化确保它能被eval处理
            correct_answer = eval(exercise)
            # 如果答案是一个假分数,将其转换为假分数的格式
            # 这里是为了之后传入的字符串符合假分数的规定
            # 便于和答案中文件对比,这样就不会对答案文件进行操作,十分巧妙
            # 转换的算法大相径庭
            if correct_answer % 1 != 0:
                correct_answer = Fraction(correct_answer).limit_denominator()
            else:
                # 这有一个问题
                # 就是如果不使用int的话会出现2.0这样的值,就无法与2配对所以保险起见还是先用int转换
                correct_answer = str(int(correct_answer))
            if isinstance(correct_answer, Fraction) and correct_answer.numerator > correct_answer.denominator:
                whole_part = correct_answer.numerator // correct_answer.denominator
                numerator = correct_answer.numerator % correct_answer.denominator
                correct_answer = f"{whole_part}’{Fraction(numerator, correct_answer.denominator)}"
            elif isinstance(correct_answer, Fraction):
                correct_answer = f"{correct_answer}"
            # 对比答案
            # 因为确保之前处理的答案符合规定,所以直接和答案文件进行比对即可了
            # 对比对了就把序号加入到对的list,反之亦然
            if correct_answer != answer.split('. ')[1].strip():
                incorrect.append(i)
            else:
                correct.append(i) 
        except Exception as e:
            # 这里的e就是报错信息,便于图形化输出
            return str(e)
    # 输出对比结果
    # 如果什么问题都没有出现就会把结果输出到out文件了,用了list的元素个数来显示不同题目数
    # list中各个元素就是对应题号
    with open(output_file, 'w') as f_out:
        f_out.write(f"正确的题目有{len(correct)}道,分别是:{correct}\n")
        f_out.write(f"错误的题目有{len(incorrect)}道,分别是:{incorrect}\n")

    return "Success"
def convert_mixed_fraction_in_expression(expression):
    # 将表达式中的每个操作数都转换为普通分数的形式
    # 将传入的表达式一个个分开
    parts = expression.split()
    # 对返回的list遍历分开对真分数和假分数进行处理
    for i, part in enumerate(parts):
        if "’" in part:
            # 如果是假分数则调用convert_mixed_fraction函数将他处理为普通分数形式,假分数形式不能计算
            parts[i] = convert_mixed_fraction(part)
    return " ".join(parts)
def convert_mixed_fraction(fraction):
    # 将假分数的形式转换为普通分数的形式
    # 再判断一次比较保险(其实之前没写上面那个函数,发现有bug才加的)
    # 逻辑和生成器的差不多
    if "’" in fraction:
        whole, frac = fraction.split("’")
        num, denom = frac.split("/")
        return f"({whole} + {num}/{denom})"
    else:
        return fraction

对比器的实现就结束了

UI实现

UI操作就只给出代码了,简单来说就是调用了tkinter的模块来实现简洁的图形化操作,其中两种选择算法就是上面的逻辑调用

import tkinter as tk
import test2 as ts
from compare import compare_answers
from tkinter import filedialog, messagebox


def open_file_dialog():
    filename = filedialog.askopenfilename()
    return filename


def open_comparator():
    def go_back():
        root.destroy()  # 关闭当前窗口
        # import UI  # 导入主界面的文件
        open_main_interface()  # 打开主界面

    root = tk.Tk()
    root.title("四则运算题目对比器")

    result_text = tk.Text(root)
    result_text.pack()

    # 在文本框中插入提示信息
    result_text.insert(tk.END, "请点击开始运行对比器,之后请依次选择题目文件,答案文件,输出结果文件\n")

    def run_comparator():
        # 跳转到输入路径的界面
        exercise_file = open_file_dialog()
        answer_file = open_file_dialog()
        output_file = open_file_dialog()
        result = compare_answers(exercise_file, answer_file, output_file)

        if result == "Success":
            with open(output_file, 'r') as f_out:
                output = f_out.read()
            result_text.delete('1.0', tk.END)
            result_text.insert(tk.END, output)
            messagebox.showinfo("提示", "文件对比成功,结果已保存到output.txt中")
        elif result == "FileNotFoundError":
            messagebox.showerror("错误", "未找到题目或答案文件")
        else:
            messagebox.showwarning("警告",
                                   f"处理题目时出错: {result}\n请确认第一次传入的文件为题目文件,第二次传入的是答案文件,并且两次文件内容是合法的")

    tk.Button(root, text="运行对比器", command=run_comparator).pack()
    tk.Button(root, text="返回", command=go_back).pack()

    root.mainloop()


def open_generator():
    def go_back():
        root.destroy()  # 关闭当前窗口
        # import UI  # 导入主界面的文件
        open_main_interface()  # 打开主界面

    def display_answers_and_notify():
        try:
            with open('exercise.txt', 'r') as f_ex, open('answer.txt', 'r') as f_ans:
                exercises = f_ex.readlines()
                answers = f_ans.readlines()
        except FileNotFoundError:
            messagebox.showerror("错误", "未找到题目或答案文件")
            return

        exercises_text.delete('1.0', tk.END)
        for exercise, answer in zip(exercises, answers):
            exercises_text.insert(tk.END, f"{exercise.strip()} {answer.split('. ')[1]}\n")

        messagebox.showinfo("提示", "答案已生成到answer文件")

    def generate_and_display_exercises():
        try:
            num_exercises = int(num_exercises_entry.get())
            if num_exercises <= 0:
                raise ValueError
        except ValueError:
            messagebox.showerror("错误", "题目数量必须是一个正整数")
            return

        try:
            num_range = range_entry.get()
            if num_range:
                min_num, max_num = map(int, num_range.split('-'))
                if min_num >= max_num or min_num <= 0:
                    raise ValueError
            else:
                min_num, max_num = 1, 10
        except ValueError:
            messagebox.showerror("错误", "随机数范围必须是两个正整数,用'-'分隔,且第一个数小于第二个数")
            return

        exercises = ts.generate_exercises(num_exercises, min_num, max_num)
        ts.write_to_files(exercises)
        messagebox.showinfo("提示", "题目已经生成到exercise.txt文件")
        # 将假分数的形式转换回 a'b/c
        exercises = [ts.format_exercise(ex) for ex in exercises]
        exercises_text.delete('1.0', tk.END)
        for i, ex in enumerate(exercises, 1):
            exercises_text.insert(tk.END, f"{i}.  {ex} =\n")

    root = tk.Tk()
    root.title("四则运算题目生成器")

    tk.Label(root, text="题目数量:").grid(row=0, column=0)
    num_exercises_entry = tk.Entry(root)
    num_exercises_entry.grid(row=0, column=1)

    tk.Label(root, text="随机数范围(可选):").grid(row=1, column=0)
    range_entry = tk.Entry(root)
    range_entry.grid(row=1, column=1)

    tk.Button(root, text="生成题目", command=generate_and_display_exercises).grid(row=2, column=0, columnspan=2)
    tk.Button(root, text="显示答案", command=display_answers_and_notify).grid(row=4, column=0, columnspan=2)
    exercises_text = tk.Text(root)
    exercises_text.grid(row=3, column=0, columnspan=2)

    tk.Button(root, text="返回", command=go_back).grid(row=5, column=0, columnspan=2)

    root.mainloop()


def open_main_interface():
    root = tk.Tk()
    root.title("四则运算题目生成器和对比器")

    def open_generator_ui():
        root.destroy()  # 关闭主界面
        open_generator()  # 打开生成器

    def open_comparator_ui():
        root.destroy()  # 关闭主界面
        open_comparator()  # 打开对比器

    tk.Label(root, text="请选择一个操作:", font=("Arial", 14)).pack(pady=10)

    tk.Button(root, text="打开生成器", command=open_generator_ui, font=("Arial", 12), width=30, height=2).pack(
        pady=10)
    tk.Button(root, text="打开对比器", command=open_comparator_ui, font=("Arial", 12), width=30, height=2).pack(
        pady=10)

    root.mainloop()


# 使用UI
open_main_interface()
UI实现的坑

这里踩过很多坑,最难搞的一个就是在我完成算法之后发现主界面关掉之后又会跳出一个主界面,关了又是一个,调试了很久发现了一个问题

# 在第一版代码中,我把生成器和对比器放在了两个文件假设为1.py 和 2.py
# 然后我在他们各自通过from ... import 引入了主界面的操作
# 我又在主界面通过from ... import 引入了两个方法的操作
# 这就出现a调用b,c || b,c也调用a,一直调用下去
# 本来是为了简洁才这么写的,没想到出了很大问题,于是我就把他们放到了一起
# 原本的主界面
import tkinter as tk
from UI_generator import open_generator
from UI_comparator import open_comparator
from tkinter import filedialog, messagebox
# 其中两个操作也有对应的返回函数
    def go_back():
        root.destroy()  # 关闭当前窗口
        import UI  # 导入主界面的文件
        open_main_interface()  # 打开主界面
# import UI 再 import 操作.....
# 放在一起就解决了问题

单元测试

单元测试的写法还是不熟练,但是这次开发在过程中边测试边开发也提高了效率

对生成器随机数生成的测试

import unittest
import test2

# 创建测试类
class TestFunctions(unittest.TestCase):
    def test_generate_number(self):
        for _ in range(100):
            num = test2.generate_number(1, 10)
            # 检查生成的数字是否在指定范围内
            # 使用assert来检测是否成功通过
            self.assertTrue(0 <= eval(num) <= 10)
            # 检查生成的数字是否为整数或分数
            if '.' not in num:
                self.assertTrue(num.isdigit() or '/' in num)

对生成文件的测试

    # 依旧在上面的类中
    def test_write_to_files(self):
        exercises = test2.generate_exercises(10, 1, 10)
        test2.write_to_files(exercises)
        # 检查文件是否存在并且内容正确
        with open('exercise.txt', 'r') as exer_file, open('answer.txt', 'r') as ans_file:
            exer_lines = exer_file.readlines()
            ans_lines = ans_file.readlines()
            self.assertEqual(len(exer_lines), len(ans_lines))
            for i, line in enumerate(exer_lines, 1):
                self.assertTrue(line.startswith(f"{i}. "))
                self.assertTrue(" = \n" in line)
            for i, line in enumerate(ans_lines, 1):
                self.assertTrue(line.startswith(f"{i}. "))
                
if __name__ == '__main__':
    unittest.main()

这个测试是针对compare函数中用于处理真分数与假分数的两个函数,其对compare函数有很大影响

import unittest
from compare import convert_mixed_fraction, convert_mixed_fraction_in_expression


class TestConversionFunctions(unittest.TestCase):
    def test_convert_mixed_fraction(self):
        self.assertEqual(convert_mixed_fraction("1’1/2"), "(1 + 1/2)")
        self.assertEqual(convert_mixed_fraction("2"), "2")

    def test_convert_mixed_fraction_in_expression(self):
        self.assertEqual(convert_mixed_fraction_in_expression("1’1/2 + 2"), "(1 + 1/2) + 2")
        self.assertEqual(convert_mixed_fraction_in_expression("2 - 1’1/2"), "2 - (1 + 1/2)")


if __name__ == '__main__':
    unittest.main()

这个测试是针对总的对比文件

import unittest
from compare import compare_answers


class TestCompareAnswers(unittest.TestCase):
    def test_compare_answers(self):
        # 创建题目文件和答案文件
        with open('exercise_test.txt', 'w') as f_ex, open('answer_test.txt', 'w') as f_ans:
            f_ex.write('1.  1 + 1 =\n2.  2 * 2 =\n')
            f_ans.write('1.  2\n2.  4\n')

        # 调用 compare_answers 函数
        compare_answers('exercise_test.txt', 'answer_test.txt', 'output_test.txt')

        # 验证输出文件的内容
        with open('output_test.txt', 'r') as f_out:
            output = f_out.read()
        self.assertEqual(output, '正确的题目有2道,分别是:[1, 2]\n错误的题目有0道,分别是:[]\n')


if __name__ == '__main__':
    unittest.main()

总结:这次是第二次使用PSP方法进行开发,在完成一个模块之后立马对该模块进行测试可以省去大量的bug产生,各个函数的接口更加顺畅

posted @ 2024-03-26 02:05  Slave-TTk  阅读(6)  评论(0编辑  收藏  举报