四則運算手冊

Slave-TTk發表於2024-03-26

四則運算器手冊

使用方法

執行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產生,各個函式的介面更加順暢

相關文章