353 lines
12 KiB
Python
353 lines
12 KiB
Python
"""Unit-тесты для калькулятора.
|
|
|
|
Запуск:
|
|
python -m pytest test_calculator.py
|
|
python -m unittest test_calculator
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import unittest
|
|
|
|
from calculator import (
|
|
CalcError,
|
|
DivisionByZeroError,
|
|
SyntaxError_,
|
|
is_exit_command,
|
|
parse_and_eval,
|
|
try_evaluate,
|
|
)
|
|
|
|
|
|
class TestBasicOperations(unittest.TestCase):
|
|
def test_addition(self):
|
|
self.assertAlmostEqual(parse_and_eval("2 + 3"), 5)
|
|
|
|
def test_subtraction(self):
|
|
self.assertAlmostEqual(parse_and_eval("10 - 4"), 6)
|
|
|
|
def test_multiplication(self):
|
|
self.assertAlmostEqual(parse_and_eval("6 * 7"), 42)
|
|
|
|
def test_division(self):
|
|
self.assertAlmostEqual(parse_and_eval("20 / 4"), 5)
|
|
|
|
def test_division_fraction(self):
|
|
self.assertAlmostEqual(parse_and_eval("1 / 4"), 0.25)
|
|
|
|
def test_negative_and_zero(self):
|
|
self.assertAlmostEqual(parse_and_eval("-5 + 5"), 0)
|
|
self.assertAlmostEqual(parse_and_eval("-5"), -5)
|
|
self.assertAlmostEqual(parse_and_eval("0 - 3"), -3)
|
|
|
|
def test_unary_signs(self):
|
|
self.assertAlmostEqual(parse_and_eval("+5"), 5)
|
|
self.assertAlmostEqual(parse_and_eval("3 + -2"), 1)
|
|
self.assertAlmostEqual(parse_and_eval("3 * -2"), -6)
|
|
|
|
|
|
class TestPrecedenceAndParentheses(unittest.TestCase):
|
|
def test_operator_precedence(self):
|
|
# + до *, без скобок: 2 + 3 * 4 = 14
|
|
self.assertAlmostEqual(parse_and_eval("2 + 3 * 4"), 14)
|
|
|
|
def test_parentheses_override(self):
|
|
self.assertAlmostEqual(parse_and_eval("(2 + 3) * 4"), 20)
|
|
|
|
def test_nested_parentheses(self):
|
|
self.assertAlmostEqual(parse_and_eval("((1 + 2) * (3 + 4))"), 21)
|
|
|
|
def test_left_to_right(self):
|
|
self.assertAlmostEqual(parse_and_eval("10 - 3 - 2"), 5)
|
|
self.assertAlmostEqual(parse_and_eval("20 / 4 / 5"), 1)
|
|
|
|
def test_chain_operations(self):
|
|
self.assertAlmostEqual(parse_and_eval("2 - 2 + 2"), 2)
|
|
|
|
|
|
class TestPercent(unittest.TestCase):
|
|
def test_percent(self):
|
|
# 50% -> 0.5
|
|
self.assertAlmostEqual(parse_and_eval("50 %"), 0.5)
|
|
|
|
def test_percent_in_term(self):
|
|
self.assertAlmostEqual(parse_and_eval("2 * 50 %"), 1.0)
|
|
|
|
def test_percent_then_plus(self):
|
|
# 10% применяется к 10 -> 0.1; затем 1 + 0.1 = 1.1
|
|
self.assertAlmostEqual(parse_and_eval("1 + 10 %"), 1.1)
|
|
|
|
|
|
class TestDivisionByZero(unittest.TestCase):
|
|
def test_divide_by_zero_raises(self):
|
|
with self.assertRaises(DivisionByZeroError):
|
|
parse_and_eval("1 / 0")
|
|
|
|
def test_divide_zero_by_zero_raises(self):
|
|
with self.assertRaises(DivisionByZeroError):
|
|
parse_and_eval("0 / 0")
|
|
|
|
def test_try_evaluate_reports_error(self):
|
|
self.assertIn("Ошибка", try_evaluate("1 / 0"))
|
|
# не падает exception, возвращает строку
|
|
self.assertIsInstance(try_evaluate("1 / 0"), str)
|
|
|
|
|
|
class TestSyntaxErrors(unittest.TestCase):
|
|
def test_empty_raises(self):
|
|
with self.assertRaises(SyntaxError_):
|
|
parse_and_eval("")
|
|
|
|
def test_trailing_operator_raises(self):
|
|
with self.assertRaises(SyntaxError_):
|
|
parse_and_eval("2 +")
|
|
|
|
def test_unbalanced_parentheses_raises(self):
|
|
with self.assertRaises(SyntaxError_):
|
|
parse_and_eval("(2 + 3")
|
|
|
|
def test_invalid_symbol_raises(self):
|
|
with self.assertRaises(SyntaxError_):
|
|
parse_and_eval("2 & 3")
|
|
|
|
def test_try_evaluate_invalid_symbol(self):
|
|
self.assertIn("Ошибка", try_evaluate("2 & 3"))
|
|
|
|
|
|
class TestExponent(unittest.TestCase):
|
|
def test_power(self):
|
|
self.assertAlmostEqual(parse_and_eval("2 ** 10"), 1024)
|
|
|
|
def test_right_associative(self):
|
|
self.assertAlmostEqual(parse_and_eval("2 ** 2 ** 3"), 256)
|
|
|
|
|
|
class TestExitCommands(unittest.TestCase):
|
|
def test_exit_commands(self):
|
|
for cmd in ("выход", "exit", " EXIT ", "Выход"):
|
|
self.assertTrue(is_exit_command(cmd), msg=cmd)
|
|
|
|
def test_non_exit(self):
|
|
for cmd in ("выходить", "exit!", "2 + 2", "exit exit"):
|
|
self.assertFalse(is_exit_command(cmd), msg=cmd)
|
|
|
|
|
|
class TestPhase0Regressions(unittest.TestCase):
|
|
def test_zero_number_atom(self):
|
|
# 0 как атом должен работать (не превращаться в 0.0 через `or`)
|
|
self.assertAlmostEqual(parse_and_eval("0"), 0.0)
|
|
self.assertAlmostEqual(parse_and_eval("0 * 5"), 0.0)
|
|
|
|
def test_unbalanced_paren_message(self):
|
|
with self.assertRaises(SyntaxError_) as ctx:
|
|
parse_and_eval("(2 + 3")
|
|
self.assertIn("')'", str(ctx.exception))
|
|
self.assertNotIn("'}'", str(ctx.exception))
|
|
|
|
def test_complex_result_raises_calc_error(self):
|
|
with self.assertRaises(CalcError):
|
|
parse_and_eval("(-8) ** 0.5")
|
|
|
|
def test_overflow_raises_calc_error(self):
|
|
with self.assertRaises(CalcError):
|
|
parse_and_eval("2 ** 100000")
|
|
|
|
def test_try_evaluate_never_raises_on_overflow(self):
|
|
self.assertIn("Ошибка", try_evaluate("2 ** 100000"))
|
|
|
|
|
|
class TestPhase1Exponents(unittest.TestCase):
|
|
def test_e_notation(self):
|
|
self.assertAlmostEqual(parse_and_eval("1e3"), 1000)
|
|
self.assertAlmostEqual(parse_and_eval("2.5E-4"), 0.00025)
|
|
self.assertAlmostEqual(parse_and_eval("1e3 + 1"), 1001)
|
|
|
|
def test_euler_constant(self):
|
|
self.assertAlmostEqual(parse_and_eval("e"), math.e)
|
|
self.assertAlmostEqual(parse_and_eval("2 * e"), 2 * math.e)
|
|
|
|
def test_digit_separators(self):
|
|
self.assertAlmostEqual(parse_and_eval("1_000_000"), 1_000_000)
|
|
self.assertAlmostEqual(parse_and_eval("1_0.5_5"), 10.55)
|
|
|
|
|
|
class TestPhase1Constants(unittest.TestCase):
|
|
def test_constants(self):
|
|
self.assertAlmostEqual(parse_and_eval("pi"), math.pi)
|
|
self.assertAlmostEqual(parse_and_eval("tau"), math.tau)
|
|
self.assertAlmostEqual(parse_and_eval("inf * 2"), math.inf)
|
|
|
|
def test_case_insensitive(self):
|
|
self.assertAlmostEqual(parse_and_eval("PI"), math.pi)
|
|
self.assertAlmostEqual(parse_and_eval("Sqrt(4)"), 2)
|
|
|
|
def test_unknown_identifier(self):
|
|
with self.assertRaises(SyntaxError_):
|
|
parse_and_eval("foo + 1")
|
|
|
|
|
|
class TestPhase1Functions(unittest.TestCase):
|
|
def test_basic(self):
|
|
self.assertAlmostEqual(parse_and_eval("sqrt(16)"), 4)
|
|
self.assertAlmostEqual(parse_and_eval("abs(-3)"), 3)
|
|
self.assertAlmostEqual(parse_and_eval("floor(2.7)"), 2)
|
|
self.assertAlmostEqual(parse_and_eval("ceil(2.1)"), 3)
|
|
self.assertAlmostEqual(parse_and_eval("round(2.567, 2)"), 2.57)
|
|
|
|
def test_logs_exp(self):
|
|
self.assertAlmostEqual(parse_and_eval("ln(e)"), 1)
|
|
self.assertAlmostEqual(parse_and_eval("log2(8)"), 3)
|
|
self.assertAlmostEqual(parse_and_eval("log10(1000)"), 3)
|
|
self.assertAlmostEqual(parse_and_eval("log(8, 2)"), 3)
|
|
self.assertAlmostEqual(parse_and_eval("exp(0)"), 1)
|
|
|
|
def test_trig(self):
|
|
self.assertAlmostEqual(parse_and_eval("sin(pi / 2)"), 1)
|
|
self.assertAlmostEqual(parse_and_eval("cos(0)"), 1)
|
|
self.assertAlmostEqual(parse_and_eval("atan(1) * 4"), math.pi)
|
|
|
|
def test_composition_precedence(self):
|
|
self.assertAlmostEqual(parse_and_eval("sqrt(16) + 1"), 5)
|
|
self.assertAlmostEqual(parse_and_eval("2 * sqrt(9)"), 6)
|
|
self.assertAlmostEqual(parse_and_eval("sqrt(2) ** 2"), 2)
|
|
self.assertAlmostEqual(parse_and_eval("-sqrt(4)"), -2)
|
|
|
|
def test_domain_error_is_calcerror(self):
|
|
with self.assertRaises(CalcError):
|
|
parse_and_eval("sqrt(-1)")
|
|
with self.assertRaises(CalcError):
|
|
parse_and_eval("log(0)")
|
|
|
|
def test_arity_error(self):
|
|
with self.assertRaises(SyntaxError_):
|
|
parse_and_eval("sqrt(1, 2)")
|
|
with self.assertRaises(SyntaxError_):
|
|
parse_and_eval("sqrt()")
|
|
|
|
def test_missing_parens_message(self):
|
|
with self.assertRaises(SyntaxError_) as ctx:
|
|
parse_and_eval("sqrt 4")
|
|
self.assertIn("sqrt", str(ctx.exception))
|
|
|
|
def test_unbalanced_call(self):
|
|
with self.assertRaises(SyntaxError_):
|
|
parse_and_eval("sqrt(16")
|
|
|
|
|
|
class TestPhase1FloorDivMod(unittest.TestCase):
|
|
def test_floordiv(self):
|
|
self.assertAlmostEqual(parse_and_eval("7 // 2"), 3)
|
|
self.assertAlmostEqual(parse_and_eval("-7 // 2"), -4)
|
|
|
|
def test_binary_mod(self):
|
|
self.assertAlmostEqual(parse_and_eval("7 % 3"), 1)
|
|
self.assertAlmostEqual(parse_and_eval("10 % 2.5"), 0)
|
|
|
|
def test_mod_vs_percent_rule(self):
|
|
# постфиксный процент без правого операнда
|
|
self.assertAlmostEqual(parse_and_eval("50 %"), 0.5)
|
|
self.assertAlmostEqual(parse_and_eval("2 * 50 %"), 1.0)
|
|
self.assertAlmostEqual(parse_and_eval("1 + 10 %"), 1.1)
|
|
|
|
def test_mod_with_paren_rhs(self):
|
|
self.assertAlmostEqual(parse_and_eval("7 % (3 + 1)"), 3)
|
|
|
|
def test_div_zero_variants(self):
|
|
with self.assertRaises(DivisionByZeroError):
|
|
parse_and_eval("5 // 0")
|
|
with self.assertRaises(DivisionByZeroError):
|
|
parse_and_eval("5 % 0")
|
|
|
|
|
|
class TestPhase1Integration(unittest.TestCase):
|
|
def test_combined_expression(self):
|
|
self.assertAlmostEqual(parse_and_eval("sqrt(3 ** 2 + 4 ** 2)"), 5)
|
|
self.assertAlmostEqual(parse_and_eval("1e3 * 10 %"), 100)
|
|
self.assertAlmostEqual(parse_and_eval("round(pi, 3)"), 3.142)
|
|
|
|
def test_try_evaluate_formats(self):
|
|
# огромно, но конечно (.12g)
|
|
self.assertEqual(try_evaluate("2 ** 1e2"), "1.26765060023e+30")
|
|
self.assertEqual(try_evaluate("sqrt(9)"), "3")
|
|
self.assertIn("Ошибка", try_evaluate("unknownvar"))
|
|
|
|
def test_float_artifact_cleaned(self):
|
|
self.assertEqual(try_evaluate("0.1 + 0.2"), "0.3")
|
|
|
|
def test_ans_in_env(self):
|
|
env = {"ans": 41.0}
|
|
self.assertAlmostEqual(parse_and_eval("1 + ans", env), 42.0)
|
|
|
|
def test_env_variable_overrides_constant(self):
|
|
env = {"e": 1.0}
|
|
self.assertAlmostEqual(parse_and_eval("e", env), 1.0)
|
|
|
|
def test_error_mentions_position(self):
|
|
with self.assertRaises(SyntaxError_) as ctx:
|
|
parse_and_eval("2 & 3")
|
|
self.assertIn("позиция", str(ctx.exception))
|
|
self.assertIn("3", str(ctx.exception))
|
|
|
|
def test_inf_display(self):
|
|
self.assertEqual(try_evaluate("inf"), "inf")
|
|
|
|
|
|
class TestPhase2Session(unittest.TestCase):
|
|
"""Поведение Session.process (cli-команды, ans, история)."""
|
|
|
|
def test_ans_updates_after_success(self):
|
|
from cli import Session
|
|
s = Session()
|
|
self.assertEqual(s.process("2 + 3"), ["5"])
|
|
self.assertEqual(s.process("ans * 2"), ["10"])
|
|
|
|
def test_ans_not_updated_on_error(self):
|
|
from cli import Session
|
|
s = Session()
|
|
s.process("sqrt(16)")
|
|
before = s.env["ans"]
|
|
s.process("sqrt(-1)")
|
|
self.assertEqual(s.env["ans"], before)
|
|
|
|
def test_vars_command(self):
|
|
from cli import Session
|
|
s = Session()
|
|
s.process("2 + 3")
|
|
self.assertIn("ans", s.process("vars")[0])
|
|
|
|
def test_hist_command_populated(self):
|
|
from cli import Session
|
|
s = Session()
|
|
s.process("sqrt(16)")
|
|
s.process("1 + 1")
|
|
self.assertGreaterEqual(len(s.process("hist")), 2)
|
|
|
|
def test_reset_clears_state(self):
|
|
from cli import Session
|
|
s = Session()
|
|
s.process("2 + 3")
|
|
self.assertEqual(
|
|
s.process("reset"),
|
|
["Сессия сброшена (ans = 0, история пуста)."],
|
|
)
|
|
|
|
def test_help_command(self):
|
|
from cli import Session
|
|
s = Session()
|
|
self.assertIn("Функции", s.process("help")[0])
|
|
|
|
def test_exit_returns_none(self):
|
|
from cli import Session
|
|
s = Session()
|
|
self.assertIsNone(s.process("выход"))
|
|
self.assertIsNone(s.process("exit"))
|
|
|
|
def test_empty_line_ignored(self):
|
|
from cli import Session
|
|
s = Session()
|
|
self.assertEqual(s.process(" "), [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|