From 732d176f011d0e95fd4a85a7756428689fafd84c Mon Sep 17 00:00:00 2001 From: TqvNoCode <92263302+TqvNoCode@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:47:16 +0700 Subject: [PATCH] bug fix --- .github/dependabot.yml | 11 + .github/workflows/ci.yml | 7 +- AGENTS.md | 32 ++- CHANGELOG.md | 19 ++ CONTRIBUTING.md | 61 ++++ SECURITY.md | 36 +++ mathstudio.desktop | 12 - mathstudio.spec | 77 ----- pyproject.toml | 30 ++ src/math_app/__init__.py | 2 +- .../application/use_cases/solve_problem.py | 158 ++++++++++- src/math_app/input/parser.py | 21 +- src/math_app/input/validator.py | 88 +++++- src/math_app/main.py | 4 + src/math_app/presentation/app.py | 2 +- .../presentation/components/input_bar.py | 2 +- .../presentation/components/math_view.py | 8 +- .../components/operation_catalog.py | 16 ++ .../components/problem_statement.py | 19 +- .../presentation/components/solution_view.py | 23 +- .../components/variation_table.py | 1 - .../presentation/services/analysis_service.py | 12 +- .../presentation/services/plot_3d_service.py | 1 - src/math_app/solvers/algebra/__init__.py | 5 + .../solvers/algebra/equation_solver.py | 262 ++++++++++++++++++ .../calculus/differentiation/rule_engine.py | 4 +- .../calculus/geometry/surface_area_solver.py | 2 +- .../calculus/integration/rule_engine.py | 7 +- .../integration/surface_integral_solver.py | 2 +- src/math_app/solvers/calculus/limit/solver.py | 2 +- .../calculus/pedagogical_derivation.py | 7 +- src/math_app/solvers/linear_algebra/solver.py | 7 +- .../calculus/integral_verifier.py | 6 + .../calculus/surface_area_verifier.py | 6 + .../calculus/surface_integral_verifier.py | 1 - .../test_unified_pedagogical_derivation.py | 1 - tests/unit/application/test_solve_problem.py | 47 ++++ tests/unit/domain/test_dtos.py | 3 +- tests/unit/input/test_security_remediation.py | 28 +- .../presentation/test_branding_and_footer.py | 2 - .../test_comprehensive_fixes_regression.py | 2 - .../test_full_multivariable_pipeline.py | 2 +- ...test_integration_and_3d_axes_regression.py | 13 +- .../presentation/test_operation_catalog.py | 7 + .../presentation/test_shutdown_lifecycle.py | 1 - .../test_solution_view_rendering.py | 34 ++- .../test_submission_pipeline_regression.py | 1 - .../test_ui_rendering_regression.py | 23 +- .../solvers/algebra/test_equation_solver.py | 80 ++++++ .../calculus/test_derivation_regression.py | 4 +- .../solvers/calculus/test_limit_solver.py | 1 - tests/unit/solvers/calculus/test_rules.py | 1 - .../verification/test_integral_verifier.py | 81 ++++++ .../test_surface_area_verifier.py | 76 +++++ uv.lock | 126 ++++++++- 55 files changed, 1301 insertions(+), 185 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md delete mode 100644 mathstudio.desktop delete mode 100644 mathstudio.spec create mode 100644 src/math_app/solvers/algebra/__init__.py create mode 100644 src/math_app/solvers/algebra/equation_solver.py create mode 100644 tests/unit/solvers/algebra/test_equation_solver.py create mode 100644 tests/unit/verification/test_integral_verifier.py create mode 100644 tests/unit/verification/test_surface_area_verifier.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..696b5d7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b220d10..ca1b18a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,5 +33,8 @@ jobs: - name: Install dependencies run: uv sync --frozen --dev - - name: Run test suite - run: uv run pytest + - name: Run linter + run: uv run ruff check . + + - name: Run test suite with coverage + run: uv run pytest --cov=src --cov-report=term-missing --cov-fail-under=70 diff --git a/AGENTS.md b/AGENTS.md index bf2f5d2..7fae5d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,15 +1,22 @@ -# MathStudio — Agent Guidelines +# MathWeave Studio — Agent Guidelines ## Project goals -Build a cross-platform mathematics learning application using -Python 3.14+, NiceGUI, SymPy, NumPy and Plotly. +Build a cross-platform mathematics learning and research application using +Python 3.14+, NiceGUI, SymPy, NumPy, and Plotly. ## Architecture -- Follow Clean Architecture with clear dependency boundaries. +- Follow Clean Architecture with clear dependency boundaries: + - `domain`: Pure business rules, enums, exceptions, and immutability models (no framework dependencies). + - `application`: Use cases (`SolveProblemUseCase`), ports (`Solver`, `SolverRegistry`, `Verifier`), and typed DTOs. + - `input`: AST-based validation, parser, and whitelist security bounds. + - `solvers`: Pure mathematical solvers (calculus, algebra, linear algebra). + - `verification`: Independent symbolic and numerical verifiers. + - `rendering`: LaTeX and MathML converters. + - `presentation`: NiceGUI UI components, services, and layout. - UI must call application use cases, never solvers directly. -- Keep domain and solver code independent of NiceGUI and Plotly. -- Use typed DTOs at application boundaries. +- Keep domain, input, and solver code strictly independent of NiceGUI and Plotly. +- Use typed DTOs (`ProblemRequest`, `SolutionResponse`, `VerificationResult`, `SolutionStep`) at application boundaries. - Prefer simple composition over unnecessary inheritance. - Add abstractions only when they solve a concrete problem. @@ -20,6 +27,7 @@ Python 3.14+, NiceGUI, SymPy, NumPy and Plotly. - Use exact arithmetic where possible. - Verify results independently with SymPy or mathematical invariants. - If a step cannot be justified, report that limitation honestly. +- Verification failures must never be reported as successful verification. ## Development workflow - Inspect the existing code before making changes. @@ -29,9 +37,17 @@ Python 3.14+, NiceGUI, SymPy, NumPy and Plotly. - Do not add dependencies without explaining why. - Never delete user data or run destructive commands without approval. +## Common commands +- Sync environment: `uv sync` +- Launch app: `uv run python run.py` +- Run test suite: `uv run pytest` +- Run coverage: `uv run pytest --cov=src --cov-report=term-missing` +- Lint code: `uv run ruff check .` +- Build package: `pyinstaller mathweave-studio.spec` + ## Testing -- Add tests for every new solver and transformation rule. -- Include valid inputs, invalid inputs and boundary cases. +- Add tests for every new solver, verifier, and transformation rule. +- Include valid inputs, invalid inputs, boundary cases, and adversarial denial-of-service cases. - Run relevant tests after every change. - Run the full test suite before declaring a task complete. - Report commands executed and any tests that could not run. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..930f1b9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-10-23 + +### Added +- **Equation Solver**: Implemented algebraic equation solving (`SOLVE_EQUATION`) with standard form transformation, root finding, factor display, and independent root verification. +- **Process Isolation & Hard Timeouts**: Added robust OS worker process isolation (`multiprocessing.Process` via `spawn`) with hard termination on configurable timeout for CPU-bound symbolic calculations. +- **Expression Complexity Hardening**: Added AST node count bounds, constant magnitude limits, logarithmic power magnitude guards, and strict prevention of nested exponent towers (e.g. `x**(9**9**9)`, `9**9**9**9`). +- **HTML Sanitization**: Added explicit HTML escaping in mathematical view fallback rendering to prevent script and event-handler injection. +- **Independent Verifier Tests**: Added dedicated unit test suites for `IntegralVerifier` and `SurfaceAreaVerifier`. +- **Release Readiness**: Added `SECURITY.md`, `CONTRIBUTING.md`, `.github/dependabot.yml`, and configured Ruff linter quality gates in CI. + +### Security +- **certifi upgrade**: Verified and remediated `PYSEC-2026-4320` by updating `certifi` from `2026.7.22` to `2026.10.23` in `uv.lock`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7499f65 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing to MathWeave Studio + +Thank you for your interest in contributing to MathWeave Studio! This guide describes how to set up the development environment, follow repository conventions, and submit contributions. + +## Code of Conduct & Architectural Principles + +MathWeave Studio adheres to Clean Architecture and strict mathematical correctness: + +1. **Clean Architecture Boundaries**: + - `domain`: Pure business models, immutability, enums, and exceptions. + - `application`: Use cases, solver/verifier interfaces, and typed DTOs. + - `input`: AST-based validation and parsing with strict whitelists and complexity bounds. + - `solvers`: Pure mathematical solvers (calculus, algebra, linear algebra). + - `verification`: Independent symbolic and numerical verifiers. + - `rendering`: LaTeX and MathML rendering. + - `presentation`: NiceGUI components and user interface. + - Domain and solver code must **never** import or depend on NiceGUI or Plotly. + +2. **Mathematical Correctness**: + - Never invent a mathematical rule or step. + - Distinguish the final result from the derivation steps. + - Preserve domain restrictions and relevant assumptions. + - Use exact arithmetic where feasible. + - Verify results independently using invariants or alternative mathematical methods. + +## Development Setup + +The project uses `uv` for fast, reproducible dependency management and Python 3.14+. + +```bash +# 1. Clone repository +git clone https://github.com/TqvNoCode/math_app.git +cd math_app + +# 2. Sync virtual environment and dependencies +uv sync + +# 3. Launch application in development mode +uv run python run.py +``` + +## Running Tests and Linting + +Before submitting a Pull Request, ensure that all automated checks pass: + +```bash +# Run full automated test suite +uv run pytest + +# Run tests with coverage report +uv run pytest --cov=src --cov-report=term-missing + +# Run code linter +uv run ruff check . +``` + +## Pull Request Guidelines + +1. Keep PRs focused on a single feature, bug fix, or mathematical rule. +2. Include unit tests covering valid inputs, invalid inputs, and boundary cases. +3. Verify that regression suites and Clean Architecture boundaries remain intact. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d903e04 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ +# Security Policy + +## Supported Versions + +MathWeave Studio is under active development. Security updates are applied to the latest stable release line. + +| Version | Supported | +| ------- | ------------------ | +| 0.1.x | :white_check_mark: | +| < 0.1.0 | :x: | + +## Reporting a Vulnerability + +We take the security of MathWeave Studio seriously. If you discover a security vulnerability, we appreciate your help in disclosing it responsibly. + +### How to report + +1. **Do not disclose the issue publicly** in open GitHub issues, pull requests, or public forums. +2. Please report security issues via GitHub's **Private Vulnerability Reporting** feature on the repository: + - Navigate to the repository's **Security** tab. + - Select **Report a vulnerability**. +3. Alternatively, if private vulnerability reporting is unavailable, reach out to the project maintainers via the email address listed in `pyproject.toml` (`92263302+TqvNoCode@users.noreply.github.com`). + +### Information to include in your report + +To help us investigate and triage effectively, please include: +- A clear description of the vulnerability and its potential impact. +- Step-by-step instructions or a minimal Proof of Concept (PoC) expression/input demonstrating the issue. +- Operating system, Python version, and dependency versions. +- Any suggested remediation or patch if available. + +### Handling and resolution process + +- Maintainers will acknowledge receipt of the report and review the vulnerability in an isolated environment. +- If verified, a patch will be developed, tested against regression suites, and published in a subsequent release. +- A security advisory will be published upon release crediting the reporter if desired. diff --git a/mathstudio.desktop b/mathstudio.desktop deleted file mode 100644 index f1d06e5..0000000 --- a/mathstudio.desktop +++ /dev/null @@ -1,12 +0,0 @@ -[Desktop Entry] -Version=1.0 -Type=Application -Name=MathWeave Studio -GenericName=Mathematics Learning Application -Comment=Phần mềm Hỗ trợ Học tập & Nghiên cứu Toán học Chuyên sâu -Exec=mathweave-studio -Icon=mathweave-studio -Terminal=false -Categories=Education;Science;Math; -StartupNotify=true -Keywords=Math;Calculus;Algebra;Differentiation;Integration;Matrix;Plotting; diff --git a/mathstudio.spec b/mathstudio.spec deleted file mode 100644 index 4e63c0f..0000000 --- a/mathstudio.spec +++ /dev/null @@ -1,77 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - -from PyInstaller.utils.hooks import collect_data_files, collect_submodules -from pathlib import Path - -block_cipher = None - -datas = [] -datas += collect_data_files("nicegui") -datas += collect_data_files("plotly") -datas += collect_data_files("latex2mathml") -datas += [("assets", "assets")] - -hiddenimports = [] -hiddenimports += collect_submodules("math_app") -hiddenimports += collect_submodules("nicegui") -hiddenimports += collect_submodules("plotly") -hiddenimports += collect_submodules("latex2mathml") -hiddenimports += [ - "qtpy", - "PyQt6", - "PyQt6.QtCore", - "PyQt6.QtGui", - "PyQt6.QtWidgets", - "PyQt6.QtWebEngineWidgets", - "PyQt6.QtWebEngineCore", - "PyQt6.QtWebChannel", - "webview", - "webview.platforms.qt", - "uvicorn", - "uvicorn.protocols.http.h11_impl", - "uvicorn.protocols.websockets.websockets_impl", - "uvicorn.lifespan.on", -] - -a = Analysis( - ["run.py"], - pathex=["src"], - binaries=[], - datas=datas, - hiddenimports=hiddenimports, - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=["tkinter", "matplotlib.tests", "scipy"], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False, -) - -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - -exe = EXE( - pyz, - a.scripts, - [], - exclude_binaries=True, - name="mathweave-studio", - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - console=False, - icon="assets/icon.png", -) - -coll = COLLECT( - exe, - a.binaries, - a.zipfiles, - a.datas, - strip=False, - upx=True, - upx_exclude=[], - name="mathweave-studio", -) diff --git a/pyproject.toml b/pyproject.toml index 26f2fba..27ce622 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,4 +32,34 @@ module-name = "math_app" dev = [ "pyinstaller>=6.22.3", "pytest>=8.0.0", + "pytest-cov>=7.1.0", + "ruff>=0.17.2", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +addopts = "-ra --strict-markers" + +[tool.ruff] +line-length = 120 +target-version = "py314" + +[tool.ruff.lint] +select = ["E", "F", "W"] +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +"run.py" = ["E402"] + +[tool.coverage.run] +source = ["src"] +omit = ["*/__pycache__/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", + "if __name__ == .__main__.:", ] diff --git a/src/math_app/__init__.py b/src/math_app/__init__.py index 3ab7abd..60dd2b5 100644 --- a/src/math_app/__init__.py +++ b/src/math_app/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations try: - from importlib.metadata import PackageNotFoundError, version + from importlib.metadata import version __version__ = version("mathweave-studio") except Exception: diff --git a/src/math_app/application/use_cases/solve_problem.py b/src/math_app/application/use_cases/solve_problem.py index 6b39e6d..4162481 100644 --- a/src/math_app/application/use_cases/solve_problem.py +++ b/src/math_app/application/use_cases/solve_problem.py @@ -3,7 +3,9 @@ from __future__ import annotations from collections.abc import Sequence +import multiprocessing import time +from typing import Any from math_app.application.dtos import ProblemRequest, SolutionResponse, VerificationResult from math_app.application.ports.solver import Solver @@ -11,11 +13,155 @@ from math_app.domain.exceptions import InvalidInputError from math_app.domain.models import SolutionStatus, VerificationStatus +DEFAULT_SOLVER_TIMEOUT_SECONDS = 8.0 + + +def _worker_solve_entrypoint(solver: Solver, request: ProblemRequest, conn: Any) -> None: + """Entry point executed in isolated child process for CPU-bound symbolic computations.""" + try: + response = solver.solve(request) + conn.send(("ok", response)) + except InvalidInputError as exc: + conn.send(("invalid_input", str(exc))) + except Exception as exc: + conn.send(("error", str(exc))) + finally: + try: + conn.close() + except Exception: + pass + + +def _execute_in_isolated_process( + solver: Solver, + request: ProblemRequest, + timeout_seconds: float, +) -> SolutionResponse: + """Execute solver inside an isolated OS process with hard termination on timeout.""" + ctx = multiprocessing.get_context("spawn") + parent_conn, child_conn = ctx.Pipe() + p = ctx.Process( + target=_worker_solve_entrypoint, + args=(solver, request, child_conn), + daemon=True, + ) + t0 = time.perf_counter() + p.start() + child_conn.close() + + p.join(timeout=timeout_seconds) + + if p.is_alive(): + # Hard cancellation of hung or CPU-bound computation + try: + p.terminate() + p.join(timeout=1.0) + if p.is_alive(): + p.kill() + p.join(timeout=1.0) + except Exception: + pass + finally: + parent_conn.close() + + elapsed = (time.perf_counter() - t0) * 1000.0 + return SolutionResponse( + status=SolutionStatus.TIMEOUT, + verification=VerificationResult( + status=VerificationStatus.FAILED, + method="timeout", + details=f"Thời gian tính toán vượt quá giới hạn an toàn {timeout_seconds}s.", + ), + raw_input=request.raw_input, + canonical_input=request.raw_input.strip(), + final_result="", + final_latex="", + warnings=( + f"Quá trình giải toán vượt quá thời gian cho phép ({timeout_seconds} giây) và đã bị ngắt an toàn (Computation timed out).", + ), + execution_time_ms=elapsed, + ) + + # Process completed within timeout + elapsed = (time.perf_counter() - t0) * 1000.0 + try: + if parent_conn.poll(): + status_tag, payload = parent_conn.recv() + if status_tag == "ok": + resp: SolutionResponse = payload + if resp.execution_time_ms <= 0.0: + resp = SolutionResponse( + status=resp.status, + verification=resp.verification, + raw_input=resp.raw_input, + canonical_input=resp.canonical_input, + final_result=resp.final_result, + final_latex=resp.final_latex, + steps=resp.steps, + domain_restrictions=resp.domain_restrictions, + warnings=resp.warnings, + execution_time_ms=elapsed, + ) + return resp + elif status_tag == "invalid_input": + return SolutionResponse( + status=SolutionStatus.INVALID_INPUT, + verification=VerificationResult( + status=VerificationStatus.NOT_APPLICABLE, + method="none", + details=payload, + ), + raw_input=request.raw_input, + canonical_input=request.raw_input.strip(), + final_result="", + final_latex="", + warnings=(payload,), + execution_time_ms=elapsed, + ) + else: + return SolutionResponse( + status=SolutionStatus.CANNOT_SOLVE, + verification=VerificationResult( + status=VerificationStatus.NOT_APPLICABLE, + method="none", + details=f"Solver error: {payload}", + ), + raw_input=request.raw_input, + canonical_input=request.raw_input.strip(), + final_result="", + final_latex="", + warnings=(f"Solver encountered an error: {payload}",), + execution_time_ms=elapsed, + ) + else: + exitcode = p.exitcode + return SolutionResponse( + status=SolutionStatus.ERROR, + verification=VerificationResult( + status=VerificationStatus.FAILED, + method="worker_crash", + details=f"Worker process exited unexpectedly with code {exitcode}.", + ), + raw_input=request.raw_input, + canonical_input=request.raw_input.strip(), + final_result="", + final_latex="", + warnings=(f"Tiến trình tính toán kết thúc đột ngột (Worker crashed with exit code {exitcode}).",), + execution_time_ms=elapsed, + ) + finally: + parent_conn.close() + class SolveProblemUseCase: """Orchestrates problem solving via abstract SolverRegistry or solver sequence.""" - def __init__(self, registry: SolverRegistry | Sequence[Solver]) -> None: + def __init__( + self, + registry: SolverRegistry | Sequence[Solver], + default_timeout: float | None = None, + ) -> None: + self.default_timeout = default_timeout if hasattr(registry, "get"): self._registry: SolverRegistry | None = registry self._solvers: Sequence[Solver] | None = None @@ -23,7 +169,11 @@ def __init__(self, registry: SolverRegistry | Sequence[Solver]) -> None: self._registry = None self._solvers = list(registry) - def execute(self, request: ProblemRequest) -> SolutionResponse: + def execute( + self, + request: ProblemRequest, + timeout_seconds: float | None = None, + ) -> SolutionResponse: start_time = time.perf_counter() cleaned_input = request.raw_input.strip() @@ -112,6 +262,10 @@ def execute(self, request: ProblemRequest) -> SolutionResponse: execution_time_ms=elapsed, ) + effective_timeout = timeout_seconds if timeout_seconds is not None else self.default_timeout + if effective_timeout is not None and effective_timeout > 0: + return _execute_in_isolated_process(solver, request, effective_timeout) + try: response = solver.solve(request) elapsed = (time.perf_counter() - start_time) * 1000.0 diff --git a/src/math_app/input/parser.py b/src/math_app/input/parser.py index 8215237..0f82e93 100644 --- a/src/math_app/input/parser.py +++ b/src/math_app/input/parser.py @@ -12,7 +12,9 @@ ALLOWED_COMMON_VARIABLES, ALLOWED_CONSTANTS, ALLOWED_FUNCTIONS, + MAX_CONSTANT_VALUE, MAX_EXPONENT_VALUE, + evaluate_constant_exponent, validate_expression_syntax, ) @@ -96,8 +98,16 @@ def visit_Constant(self, node: ast.Constant) -> sp.Expr: if type(node.value) is bool: raise InvalidInputError("Giá trị boolean không được hỗ trợ trong biểu thức toán học (Booleans are not supported).") if isinstance(node.value, int): + if abs(node.value) > MAX_CONSTANT_VALUE: + raise InvalidInputError( + f"Giá trị hằng số {node.value} vượt quá giới hạn an toàn tối đa (Constant exceeds maximum allowed value): {MAX_CONSTANT_VALUE}." + ) return sp.Integer(node.value) elif isinstance(node.value, float): + if abs(node.value) > MAX_CONSTANT_VALUE: + raise InvalidInputError( + f"Giá trị hằng số {node.value} vượt quá giới hạn an toàn tối đa (Constant exceeds maximum allowed value): {MAX_CONSTANT_VALUE}." + ) return sp.Float(node.value) raise InvalidInputError(f"Giá trị hằng số '{node.value}' không được hỗ trợ (Disallowed constant value).") @@ -155,10 +165,15 @@ def visit_BinOp(self, node: ast.BinOp) -> sp.Expr: left_args = left.args if isinstance(left, sp.Mul) else (left,) return sp.Mul(*left_args, sp.Pow(right, -1, evaluate=False), evaluate=False) elif isinstance(op, ast.Pow): - if isinstance(node.right, ast.Constant) and isinstance(node.right.value, (int, float)): - if abs(node.right.value) > MAX_EXPONENT_VALUE: + if any(isinstance(n, ast.Pow) for n in ast.walk(node.right)): + raise InvalidInputError( + "Số mũ không được chứa phép toán lũy thừa lồng nhau (Nested exponents/powers in exponent are not allowed)." + ) + exp_val = evaluate_constant_exponent(node.right) + if exp_val is not None: + if abs(exp_val) > MAX_EXPONENT_VALUE: raise InvalidInputError( - f"Số mũ {node.right.value} vượt quá giới hạn an toàn tối đa (exceeds maximum allowed value): {MAX_EXPONENT_VALUE}." + f"Số mũ {exp_val} vượt quá giới hạn an toàn tối đa (exceeds maximum allowed value): {MAX_EXPONENT_VALUE}." ) return (left ** right) if self.evaluate else sp.Pow(left, right, evaluate=False) raise InvalidInputError(f"Toán tử '{type(op).__name__}' không được hỗ trợ (Disallowed binary operator).") diff --git a/src/math_app/input/validator.py b/src/math_app/input/validator.py index 6652bf5..4b53ce1 100644 --- a/src/math_app/input/validator.py +++ b/src/math_app/input/validator.py @@ -3,12 +3,16 @@ from __future__ import annotations import ast +import math from math_app.domain.exceptions import InvalidInputError MAX_EXPRESSION_LENGTH = 200 MAX_AST_DEPTH = 12 +MAX_AST_NODES = 100 MAX_EXPONENT_VALUE = 50.0 +MAX_CONSTANT_VALUE = 10_000_000.0 # 10^7 +MAX_CONSTANT_LOG_POWER = 7.0 # log10(base^exp) > 7 means > 10,000,000 ALLOWED_FUNCTIONS = frozenset({ "sin", "cos", "tan", "sec", "csc", "cot", @@ -26,6 +30,53 @@ def get_ast_depth(node: ast.AST) -> int: return 1 + max(get_ast_depth(child) for child in ast.iter_child_nodes(node)) +def evaluate_constant_exponent(node: ast.AST) -> float | None: + """Safely evaluate constant arithmetic in exponent without computing dangerous powers. + + Returns float value if node is purely constant arithmetic (+, -, *, /), or None if symbolic. + Raises InvalidInputError if division by zero or disallowed operation is detected. + """ + if isinstance(node, ast.Constant): + if isinstance(node.value, (int, float)) and type(node.value) is not bool: + return float(node.value) + raise InvalidInputError(f"Giá trị hằng số '{node.value}' không được hỗ trợ trong số mũ (Disallowed constant in exponent).") + elif isinstance(node, ast.UnaryOp): + val = evaluate_constant_exponent(node.operand) + if val is None: + return None + if isinstance(node.op, ast.UAdd): + return +val + elif isinstance(node.op, ast.USub): + return -val + raise InvalidInputError(f"Toán tử một ngôi '{type(node.op).__name__}' không được hỗ trợ trong số mũ.") + elif isinstance(node, ast.BinOp): + if isinstance(node.op, ast.Pow): + raise InvalidInputError( + "Số mũ không được chứa phép toán lũy thừa lồng nhau (Nested exponents/powers in exponent are not allowed)." + ) + left_val = evaluate_constant_exponent(node.left) + right_val = evaluate_constant_exponent(node.right) + if left_val is None or right_val is None: + return None + if isinstance(node.op, ast.Add): + return left_val + right_val + elif isinstance(node.op, ast.Sub): + return left_val - right_val + elif isinstance(node.op, ast.Mult): + return left_val * right_val + elif isinstance(node.op, ast.Div): + if abs(right_val) < 1e-12: + raise InvalidInputError("Phép chia cho 0 trong số mũ không xác định (Division by zero in exponent).") + return left_val / right_val + raise InvalidInputError(f"Toán tử '{type(node.op).__name__}' không được hỗ trợ trong số mũ.") + elif isinstance(node, ast.Name): + return None + elif isinstance(node, ast.Call): + return None + else: + raise InvalidInputError(f"Cấu trúc không hợp lệ trong số mũ: '{type(node).__name__}'.") + + def validate_expression_syntax( raw_expression: str, target_variable: str = "x", @@ -69,6 +120,12 @@ def validate_expression_syntax( f"Độ phức tạp của biểu thức vượt quá giới hạn cho phép (maximum allowable depth): {MAX_AST_DEPTH} cấp." ) + node_count = sum(1 for _ in ast.walk(tree)) + if node_count > MAX_AST_NODES: + raise InvalidInputError( + f"Số lượng toán tử/nút trong biểu thức ({node_count}) vượt quá giới hạn cho phép: {MAX_AST_NODES}." + ) + allowed_names = {target_variable} | ALLOWED_CONSTANTS | ALLOWED_FUNCTIONS | ALLOWED_COMMON_VARIABLES if allowed_variables: allowed_names = allowed_names | set(allowed_variables) @@ -79,12 +136,29 @@ def validate_expression_syntax( elif isinstance(node, ast.BinOp): if not isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow)): raise InvalidInputError(f"Toán tử '{type(node.op).__name__}' không được hỗ trợ (Disallowed binary operator).") - # If power operation, ensure constant exponent does not exceed limit - if isinstance(node.op, ast.Pow) and isinstance(node.right, ast.Constant): - if isinstance(node.right.value, (int, float)) and abs(node.right.value) > MAX_EXPONENT_VALUE: + # If power operation, restrict exponent complexity and value + if isinstance(node.op, ast.Pow): + # 1. Disallow nested powers inside exponent + if any(isinstance(n, ast.Pow) for n in ast.walk(node.right)): raise InvalidInputError( - f"Số mũ {node.right.value} vượt quá giới hạn an toàn tối đa (exceeds maximum allowed value): {MAX_EXPONENT_VALUE}." + "Số mũ không được chứa phép toán lũy thừa lồng nhau (Nested exponents/powers in exponent are not allowed)." ) + # 2. Evaluate constant exponent if purely arithmetic + exp_val = evaluate_constant_exponent(node.right) + if exp_val is not None: + if abs(exp_val) > MAX_EXPONENT_VALUE: + raise InvalidInputError( + f"Số mũ {exp_val} vượt quá giới hạn an toàn tối đa (exceeds maximum allowed value): {MAX_EXPONENT_VALUE}." + ) + # 3. Guard against huge constant powers (e.g. 9^9, 10^8) + if isinstance(node.left, ast.Constant) and isinstance(node.left.value, (int, float)) and type(node.left.value) is not bool: + base_val = abs(float(node.left.value)) + if base_val > 1.0 and exp_val > 0: + log_size = exp_val * math.log10(base_val) + if log_size > MAX_CONSTANT_LOG_POWER: + raise InvalidInputError( + f"Giá trị lũy thừa hằng số {node.left.value}^{exp_val} vượt quá giới hạn an toàn tối đa (Constant power exceeds safe limit)." + ) elif isinstance(node, (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow)): continue elif isinstance(node, ast.UnaryOp): @@ -95,8 +169,12 @@ def validate_expression_syntax( elif isinstance(node, ast.Load): continue elif isinstance(node, ast.Constant): - if not isinstance(node.value, (int, float)): + if type(node.value) is bool or not isinstance(node.value, (int, float)): raise InvalidInputError(f"Giá trị hằng số '{node.value}' không được hỗ trợ (Disallowed constant value).") + if abs(node.value) > MAX_CONSTANT_VALUE: + raise InvalidInputError( + f"Giá trị hằng số {node.value} vượt quá giới hạn an toàn tối đa (Constant exceeds maximum allowed value): {MAX_CONSTANT_VALUE}." + ) elif isinstance(node, ast.Name): if node.id not in allowed_names: raise InvalidInputError( diff --git a/src/math_app/main.py b/src/math_app/main.py index fad533b..824550f 100644 --- a/src/math_app/main.py +++ b/src/math_app/main.py @@ -10,6 +10,7 @@ from math_app.application.use_cases.solve_problem import SolveProblemUseCase from math_app.domain.models import ProblemType from math_app.presentation.app import create_app +from math_app.solvers.algebra.equation_solver import EquationSolver from math_app.solvers.calculus.differentiation.solver import DerivativeSolver from math_app.solvers.calculus.geometry.arc_length_solver import ArcLengthSolver from math_app.solvers.calculus.geometry.surface_area_solver import SurfaceAreaSolver @@ -76,6 +77,9 @@ def build_application() -> SolveProblemUseCase: limit_solver = LimitSolver() registry.register(ProblemType.LIMIT, limit_solver) + equation_solver = EquationSolver() + registry.register(ProblemType.SOLVE_EQUATION, equation_solver) + # Linear algebra solver matrix_solver = MatrixSolver() registry.register(ProblemType.MATRIX_ADD_SUB, matrix_solver) diff --git a/src/math_app/presentation/app.py b/src/math_app/presentation/app.py index 31d6696..5c2cce0 100644 --- a/src/math_app/presentation/app.py +++ b/src/math_app/presentation/app.py @@ -785,7 +785,7 @@ async def handle_submit() -> None: ) try: - response: SolutionResponse = await asyncio.to_thread(use_case.execute, request) + response: SolutionResponse = await asyncio.to_thread(use_case.execute, request, 8.0) except Exception as exc: results_container.clear() with results_container: diff --git a/src/math_app/presentation/components/input_bar.py b/src/math_app/presentation/components/input_bar.py index 8ccdb17..02784e1 100644 --- a/src/math_app/presentation/components/input_bar.py +++ b/src/math_app/presentation/components/input_bar.py @@ -25,7 +25,7 @@ def render_input_bar(on_solve: Callable[[str, str], None]) -> tuple[ui.input, ui value="x", ).classes("w-24 text-lg") - solve_button = ui.button( + ui.button( "Tính đạo hàm", icon="calculate", color="primary", diff --git a/src/math_app/presentation/components/math_view.py b/src/math_app/presentation/components/math_view.py index 93c9c0b..13d29f6 100644 --- a/src/math_app/presentation/components/math_view.py +++ b/src/math_app/presentation/components/math_view.py @@ -81,20 +81,24 @@ def math_html(latex_str: str, block: bool = False, is_icon: bool = False) -> str try: mathml = latex2mathml.converter.convert(normalized, display="block" if block else "inline") + # Ensure rendered MathML does not contain injected script tags or event handlers + if re.search(r"<(?:script|iframe|object|embed|style)", mathml, re.IGNORECASE) or re.search(r"\bon\w+\s*=", mathml, re.IGNORECASE): + raise ValueError("Unsafe HTML tags or event handlers detected in MathML output.") if is_icon: cls = "math-icon math-container select-none pointer-events-none" else: cls = "math-block" if block else "math-container" return f'
{mathml}
' except Exception: - # Fallback to styled mathematical font span + # Fallback to styled mathematical font span with explicit HTML escaping tag = "div" if block else "span" cls = ( "math-fallback font-serif text-blue-900 tracking-wide select-none" if is_icon else "math-fallback font-serif text-blue-900 tracking-wide" ) - return f'<{tag} class="{cls}">{normalized}' + safe_fallback = html.escape(normalized) + return f'<{tag} class="{cls}">{safe_fallback}' def ui_math(latex_str: str, block: bool = False, classes: str = "", is_icon: bool = False) -> ui.html: diff --git a/src/math_app/presentation/components/operation_catalog.py b/src/math_app/presentation/components/operation_catalog.py index f2c939e..6aeba6c 100644 --- a/src/math_app/presentation/components/operation_catalog.py +++ b/src/math_app/presentation/components/operation_catalog.py @@ -167,6 +167,22 @@ def is_calculus(self) -> bool: ("lim 1/x khi x->oo", "1/x"), ), ), + OperationDef( + problem_type=ProblemType.SOLVE_EQUATION, + title="Giải phương trình", + domain="Đại số & Giải tích", + description="Tìm nghiệm chính xác của phương trình đại số, kèm các bước phân tích nhân tử và kiểm chứng nghiệm.", + icon="calculate", + math_icon=r"f(x) = 0", + default_input="x^2 - 5*x + 6 = 0", + target_var="x", + examples=( + ("Bậc hai: x^2 - 5x + 6 = 0", "x^2 - 5*x + 6 = 0"), + ("Bậc nhất: 2x + 4 = 0", "2*x + 4 = 0"), + ("Hằng đẳng thức: x^2 - 4 = 0", "x^2 - 4 = 0"), + ("Vô nghiệm: x + 1 = x", "x + 1 = x"), + ), + ), OperationDef( problem_type=ProblemType.ARC_LENGTH, title="Độ dài cung (Arc Length)", diff --git a/src/math_app/presentation/components/problem_statement.py b/src/math_app/presentation/components/problem_statement.py index 4a2cbdb..daec374 100644 --- a/src/math_app/presentation/components/problem_statement.py +++ b/src/math_app/presentation/components/problem_statement.py @@ -3,11 +3,9 @@ from __future__ import annotations from typing import Any -import sympy as sp from math_app.domain.models import ProblemType from math_app.input.parser import ( - normalize_expression_string, normalize_variable_name, parse_mathematical_input, parse_matrix_input, @@ -37,6 +35,7 @@ def get_result_card_title(op_type: ProblemType) -> str: ProblemType.STOKES_THEOREM: "KẾT QUẢ ĐỊNH LÝ STOKES", ProblemType.DIVERGENCE_THEOREM: "KẾT QUẢ ĐỊNH LÝ DIVERGENCE (GAUSS)", ProblemType.LIMIT: "KẾT QUẢ GIỚI HẠN", + ProblemType.SOLVE_EQUATION: "KẾT QUẢ GIẢI PHƯƠNG TRÌNH", ProblemType.MATRIX_DETERMINANT: "KẾT QUẢ ĐỊNH THỨC", ProblemType.MATRIX_INVERSE: "KẾT QUẢ MA TRẬN NGHỊCH ĐẢO", ProblemType.MATRIX_REF: "KẾT QUẢ DẠNG BẬC THANG (REF)", @@ -265,6 +264,19 @@ def format_problem_statement_latex( except Exception: return f"\\lim_{{{clean_var} \\to 0}} \\left( {raw_input} \\right)" + # 5b. Equation Solving + if op_type == ProblemType.SOLVE_EQUATION: + try: + if "=" in raw_input: + parts = raw_input.split("=") + if len(parts) == 2: + lhs_ltx = expr_to_latex(parts[0].strip()) + rhs_ltx = expr_to_latex(parts[1].strip()) + return f"{lhs_ltx} = {rhs_ltx}" + return f"{expr_to_latex(raw_input)} = 0" + except Exception: + return f"{raw_input} = 0" + # 6. Matrix Operations try: if op_type in ( @@ -392,4 +404,7 @@ def format_final_answer_latex( if op_type == ProblemType.MATRIX_SOLVE_SYSTEM: return f"x = {raw_final_latex}" + if op_type == ProblemType.SOLVE_EQUATION: + return raw_final_latex + return raw_final_latex diff --git a/src/math_app/presentation/components/solution_view.py b/src/math_app/presentation/components/solution_view.py index 16eff0f..d425f20 100644 --- a/src/math_app/presentation/components/solution_view.py +++ b/src/math_app/presentation/components/solution_view.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import Any -import sympy as sp from nicegui import ui from math_app.application.dtos import SolutionResponse @@ -103,14 +102,24 @@ def render_solution_view( p_params = params or {} - # 1. Error / Invalid Input Card - if response.status in (SolutionStatus.INVALID_INPUT, SolutionStatus.CANNOT_SOLVE, SolutionStatus.ERROR): - with ui.card().classes("w-full p-5 border border-red-200 bg-red-50/70 text-red-900 rounded-xl shadow-xs"): + # 1. Error / Invalid Input / Timeout Card + if response.status in (SolutionStatus.INVALID_INPUT, SolutionStatus.CANNOT_SOLVE, SolutionStatus.ERROR, SolutionStatus.TIMEOUT): + is_timeout = (response.status == SolutionStatus.TIMEOUT) + card_bg = "border-amber-200 bg-amber-50/80 text-amber-900" if is_timeout else "border-red-200 bg-red-50/70 text-red-900" + title_text = ( + "Quá thời gian tính toán cho phép (Computation Timed Out)" + if is_timeout + else "Không thể hoàn thành phép tính hoặc dữ liệu không hợp lệ" + ) + icon_name = "timer_off" if is_timeout else "error" + icon_color = "warning" if is_timeout else "negative" + + with ui.card().classes(f"w-full p-5 border {card_bg} rounded-xl shadow-xs"): with ui.row().classes("items-center gap-2 mb-2"): - ui.icon("error", color="negative", size="1.5rem") - ui.label("Không thể hoàn thành phép tính hoặc dữ liệu không hợp lệ").classes("font-bold text-base text-red-900") + ui.icon(icon_name, color=icon_color, size="1.5rem") + ui.label(title_text).classes(f"font-bold text-base {'text-amber-900' if is_timeout else 'text-red-900'}") for warn in response.warnings: - ui.label(f"• {warn}").classes("text-sm text-red-800 ml-6") + ui.label(f"• {warn}").classes(f"text-sm {'text-amber-800' if is_timeout else 'text-red-800'} ml-6") if not response.warnings: ui.label("Dữ liệu đầu vào chưa đúng định dạng toán học hoặc vượt quá miền xác định.").classes("text-sm text-red-800 ml-6") return diff --git a/src/math_app/presentation/components/variation_table.py b/src/math_app/presentation/components/variation_table.py index a2e761d..c6147a9 100644 --- a/src/math_app/presentation/components/variation_table.py +++ b/src/math_app/presentation/components/variation_table.py @@ -2,7 +2,6 @@ from __future__ import annotations -import html from typing import Any from nicegui import ui diff --git a/src/math_app/presentation/services/analysis_service.py b/src/math_app/presentation/services/analysis_service.py index 73a9a89..300e93d 100644 --- a/src/math_app/presentation/services/analysis_service.py +++ b/src/math_app/presentation/services/analysis_service.py @@ -8,6 +8,9 @@ import sympy as sp from math_app.input.parser import normalize_variable_name, parse_mathematical_input +from math_app.presentation.services.plot_3d_service import ( + create_surface_plot_from_expression, +) from math_app.rendering.latex.formatter import expr_to_latex @@ -174,15 +177,6 @@ def create_2d_plot( return None -from math_app.presentation.services.plot_3d_service import ( - create_surface_plot_from_expression, - create_parametric_surface_plot, - build_3d_figure, - Plot3DConfig, - AxisDisplayPolicy, -) - - def can_plot_3d(raw_expr: str) -> bool: """Check if expression represents a two-variable real function suitable for 3D surface plotting.""" cleaned = raw_expr.strip() diff --git a/src/math_app/presentation/services/plot_3d_service.py b/src/math_app/presentation/services/plot_3d_service.py index f73039c..4f5875e 100644 --- a/src/math_app/presentation/services/plot_3d_service.py +++ b/src/math_app/presentation/services/plot_3d_service.py @@ -7,7 +7,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any import numpy as np import plotly.graph_objects as go import sympy as sp diff --git a/src/math_app/solvers/algebra/__init__.py b/src/math_app/solvers/algebra/__init__.py new file mode 100644 index 0000000..6e3aad7 --- /dev/null +++ b/src/math_app/solvers/algebra/__init__.py @@ -0,0 +1,5 @@ +"""Algebra solver module.""" + +from math_app.solvers.algebra.equation_solver import EquationSolver + +__all__ = ["EquationSolver"] diff --git a/src/math_app/solvers/algebra/equation_solver.py b/src/math_app/solvers/algebra/equation_solver.py new file mode 100644 index 0000000..f8cf554 --- /dev/null +++ b/src/math_app/solvers/algebra/equation_solver.py @@ -0,0 +1,262 @@ +"""Equation solver for single-variable algebraic equations.""" + +from __future__ import annotations + +import time +import sympy as sp + +from math_app.application.dtos import ( + ProblemRequest, + SolutionResponse, + SolutionStep, + VerificationResult, +) +from math_app.application.ports.solver import Solver +from math_app.domain.exceptions import InvalidInputError +from math_app.domain.models import ProblemType, SolutionStatus, VerificationStatus +from math_app.domain.step_content import ExpressionStepContent +from math_app.input.parser import normalize_variable_name, parse_safe_expression +from math_app.rendering.latex.formatter import expr_to_latex + + +class EquationSolver(Solver): + """Solver for algebraic equations with pedagogical step explanations and root verification.""" + + def can_solve(self, request: ProblemRequest) -> bool: + return request.problem_type == ProblemType.SOLVE_EQUATION + + def solve(self, request: ProblemRequest) -> SolutionResponse: + t0 = time.perf_counter() + raw = request.raw_input.strip() + if not raw: + raise InvalidInputError("Phương trình không được để trống (Equation cannot be empty).") + + clean_var = normalize_variable_name(request.target_variable) if request.target_variable else "x" + var = sp.Symbol(clean_var) + + # Parse LHS and RHS + if "=" in raw: + parts = raw.split("=") + if len(parts) != 2: + raise InvalidInputError("Phương trình chỉ được chứa đúng một dấu '=' (Must contain exactly one '=').") + lhs_str, rhs_str = parts[0].strip(), parts[1].strip() + if not lhs_str or not rhs_str: + raise InvalidInputError("Hai vế của phương trình không được để trống (LHS and RHS cannot be empty).") + else: + lhs_str = raw + rhs_str = "0" + + lhs_expr = parse_safe_expression(lhs_str, target_variable=clean_var, evaluate=False) + rhs_expr = parse_safe_expression(rhs_str, target_variable=clean_var, evaluate=False) + + # Standard form: f(x) = LHS - RHS = 0 + eq_expr = sp.simplify(lhs_expr - rhs_expr) + + steps: list[SolutionStep] = [] + step_num = 1 + + # Step 1: Standard form + std_latex = f"{expr_to_latex(eq_expr)} = 0" + steps.append( + SolutionStep( + step_number=step_num, + rule_name="Chuyển vế về dạng chuẩn", + description="Chuyển tất cả các số hạng sang vế trái để đưa phương trình về dạng chuẩn f(x) = 0.", + content=ExpressionStepContent( + expression_before=f"{lhs_str} = {rhs_str}", + expression_after=f"{eq_expr} = 0", + latex_before=f"{expr_to_latex(lhs_expr)} = {expr_to_latex(rhs_expr)}", + latex_after=std_latex, + ), + ) + ) + step_num += 1 + + # Check identity: 0 = 0 + if eq_expr == 0: + elapsed = (time.perf_counter() - t0) * 1000.0 + steps.append( + SolutionStep( + step_number=step_num, + rule_name="Đồng nhất thức", + description="Phương trình luôn đúng với mọi giá trị của biến thuộc tập xác định.", + content=ExpressionStepContent( + expression_before=f"{eq_expr} = 0", + expression_after="All real numbers", + latex_before=std_latex, + latex_after=r"x \in \mathbb{R}", + ), + ) + ) + return SolutionResponse( + status=SolutionStatus.SUCCESS, + verification=VerificationResult( + status=VerificationStatus.VERIFIED, + method="identity_equation_proof", + details="Phương trình nghiệm đúng với mọi giá trị thuộc tập số thực.", + ), + raw_input=request.raw_input, + canonical_input=f"{lhs_expr} = {rhs_expr}", + final_result="Vô số nghiệm (All real numbers)", + final_latex=r"S = \mathbb{R}", + steps=tuple(steps), + execution_time_ms=elapsed, + ) + + # Check inconsistency: non-zero constant = 0 + if eq_expr.is_number and eq_expr != 0: + elapsed = (time.perf_counter() - t0) * 1000.0 + steps.append( + SolutionStep( + step_number=step_num, + rule_name="Phương trình vô nghiệm", + description="Vế trái là hằng số khác 0, phương trình mâu thuẫn và không có nghiệm.", + content=ExpressionStepContent( + expression_before=f"{eq_expr} = 0", + expression_after="No solution", + latex_before=std_latex, + latex_after=r"S = \emptyset", + ), + ) + ) + return SolutionResponse( + status=SolutionStatus.SUCCESS, + verification=VerificationResult( + status=VerificationStatus.VERIFIED, + method="inconsistent_equation_proof", + details="Phương trình vô nghiệm do vế trái là hằng số khác 0.", + ), + raw_input=request.raw_input, + canonical_input=f"{lhs_expr} = {rhs_expr}", + final_result="Vô nghiệm (No solution)", + final_latex=r"S = \emptyset", + steps=tuple(steps), + execution_time_ms=elapsed, + ) + + # Solve equation + try: + solutions = sp.solve(sp.Eq(eq_expr, 0), var) + except Exception as exc: + elapsed = (time.perf_counter() - t0) * 1000.0 + return SolutionResponse( + status=SolutionStatus.CANNOT_SOLVE, + verification=VerificationResult( + status=VerificationStatus.NOT_APPLICABLE, + method="none", + details=f"Cannot solve symbolically: {exc}", + ), + raw_input=request.raw_input, + canonical_input=f"{lhs_expr} = {rhs_expr}", + final_result="", + final_latex="", + warnings=(f"Không thể giải chính xác phương trình này bằng phương pháp giải tích: {exc}",), + execution_time_ms=elapsed, + ) + + # If sp.solve returns empty list + if not solutions: + elapsed = (time.perf_counter() - t0) * 1000.0 + steps.append( + SolutionStep( + step_number=step_num, + rule_name="Kết luận nghiệm", + description="Không tìm thấy giá trị thực nào thỏa mãn phương trình.", + content=ExpressionStepContent( + expression_before=f"{eq_expr} = 0", + expression_after="No real solution", + latex_before=std_latex, + latex_after=r"S = \emptyset", + ), + ) + ) + return SolutionResponse( + status=SolutionStatus.SUCCESS, + verification=VerificationResult( + status=VerificationStatus.VERIFIED, + method="symbolic_solveset_exhaustion", + details="Chứng minh phương trình không có nghiệm trong tập số thực.", + ), + raw_input=request.raw_input, + canonical_input=f"{lhs_expr} = {rhs_expr}", + final_result="Vô nghiệm (No solution)", + final_latex=r"S = \emptyset", + steps=tuple(steps), + execution_time_ms=elapsed, + ) + + # Step 2: Show algebraic transformation / factoring + try: + factored = sp.factor(eq_expr) + if factored != eq_expr: + steps.append( + SolutionStep( + step_number=step_num, + rule_name="Phân tích nhân tử", + description="Phân tích biểu thức thành tích các nhân tử để tìm nghiệm.", + content=ExpressionStepContent( + expression_before=f"{eq_expr} = 0", + expression_after=f"{factored} = 0", + latex_before=std_latex, + latex_after=f"{expr_to_latex(factored)} = 0", + ), + ) + ) + step_num += 1 + except Exception: + pass + + # Step 3: Roots + roots_latex_parts = [f"{clean_var} = {expr_to_latex(s)}" for s in solutions] + roots_latex = ", \\quad ".join(roots_latex_parts) + roots_str_parts = [f"{clean_var} = {s}" for s in solutions] + roots_str = ", ".join(roots_str_parts) + + steps.append( + SolutionStep( + step_number=step_num, + rule_name="Tập nghiệm của phương trình", + description=f"Tìm được {len(solutions)} nghiệm của phương trình.", + content=ExpressionStepContent( + expression_before=f"{eq_expr} = 0", + expression_after=roots_str, + latex_before=std_latex, + latex_after=roots_latex, + ), + ) + ) + + # Verification: substitute each root back into eq_expr + verified = True + for sol in solutions: + try: + subbed = sp.simplify(eq_expr.subs(var, sol)) + if subbed != 0 and abs(complex(subbed.evalf())) > 1e-6: + verified = False + break + except Exception: + verified = False + break + + v_status = VerificationStatus.VERIFIED if verified else VerificationStatus.UNVERIFIED + v_details = ( + "Thay trực tiếp các nghiệm vào phương trình ban đầu xác nhận vế trái luôn bằng 0." + if verified + else "Một số nghiệm chưa thể kiểm chứng độc lập hoàn toàn." + ) + + elapsed = (time.perf_counter() - t0) * 1000.0 + return SolutionResponse( + status=SolutionStatus.SUCCESS, + verification=VerificationResult( + status=v_status, + method="algebraic_root_substitution", + details=v_details, + ), + raw_input=request.raw_input, + canonical_input=f"{lhs_expr} = {rhs_expr}", + final_result=roots_str, + final_latex=roots_latex, + steps=tuple(steps), + execution_time_ms=elapsed, + ) diff --git a/src/math_app/solvers/calculus/differentiation/rule_engine.py b/src/math_app/solvers/calculus/differentiation/rule_engine.py index e296f9b..c58b8c5 100644 --- a/src/math_app/solvers/calculus/differentiation/rule_engine.py +++ b/src/math_app/solvers/calculus/differentiation/rule_engine.py @@ -80,8 +80,8 @@ def differentiate( rule_name_vi="Tính toán ký hiệu trực tiếp", title="Tính đạo hàm trực tiếp bằng bộ giải ký hiệu", description=( - f"Đạo hàm của biểu thức được tính toán trực tiếp bằng bộ giải ký hiệu toán học " - f"do hệ thống chưa có quy tắc sư phạm phân rã chi tiết cho cấu trúc này." + "Đạo hàm của biểu thức được tính toán trực tiếp bằng bộ giải ký hiệu toán học " + "do hệ thống chưa có quy tắc sư phạm phân rã chi tiết cho cấu trúc này." ), before=expr, after=direct_result, diff --git a/src/math_app/solvers/calculus/geometry/surface_area_solver.py b/src/math_app/solvers/calculus/geometry/surface_area_solver.py index f972070..06425b7 100644 --- a/src/math_app/solvers/calculus/geometry/surface_area_solver.py +++ b/src/math_app/solvers/calculus/geometry/surface_area_solver.py @@ -10,7 +10,7 @@ from math_app.domain.exceptions import InvalidInputError from math_app.domain.models import ProblemType, SolutionStatus from math_app.domain.step_content import ExpressionStepContent -from math_app.input.parser import parse_mathematical_input, parse_safe_expression +from math_app.input.parser import parse_safe_expression from math_app.rendering.latex.formatter import expr_to_latex from math_app.verification.calculus.surface_area_verifier import SurfaceAreaVerifier diff --git a/src/math_app/solvers/calculus/integration/rule_engine.py b/src/math_app/solvers/calculus/integration/rule_engine.py index c920a97..ec2e23b 100644 --- a/src/math_app/solvers/calculus/integration/rule_engine.py +++ b/src/math_app/solvers/calculus/integration/rule_engine.py @@ -3,7 +3,6 @@ from __future__ import annotations import time -from typing import Any import sympy as sp from math_app.domain.exceptions import ComputationTimeoutError @@ -61,7 +60,7 @@ def integrate( builder.add_step( rule_name="Quy tắc lũy thừa và hằng số tích phân C" if not is_definite else "Quy tắc lũy thừa cơ bản", title="Áp dụng quy tắc tích phân của lũy thừa", - description=f"Áp dụng công thức nguyên hàm cơ bản cho hàm số bậc nhất với n = 1:", + description="Áp dụng công thức nguyên hàm cơ bản cho hàm số bậc nhất với n = 1:", before=expr, after=latex_after, equation_latex=eq_latex, @@ -232,8 +231,8 @@ def integrate( rule_name="Công cụ tích phân giải tích", title="Tính toán nguyên hàm bằng giải tích", description=( - f"Tính toán nguyên hàm trực tiếp cho biểu thức bằng công cụ giải tích toán học " - f"do hệ thống chưa có quy tắc sư phạm phân rã chi tiết cho cấu trúc này." + "Tính toán nguyên hàm trực tiếp cho biểu thức bằng công cụ giải tích toán học " + "do hệ thống chưa có quy tắc sư phạm phân rã chi tiết cho cấu trúc này." ), before=expr, after=latex_after, diff --git a/src/math_app/solvers/calculus/integration/surface_integral_solver.py b/src/math_app/solvers/calculus/integration/surface_integral_solver.py index ffa1c4a..c43080a 100644 --- a/src/math_app/solvers/calculus/integration/surface_integral_solver.py +++ b/src/math_app/solvers/calculus/integration/surface_integral_solver.py @@ -156,7 +156,7 @@ def solve(self, request: ProblemRequest) -> SolutionResponse: SolutionStep( step_number=step_idx, rule_name=f"Thiết lập vi phân thông lượng ({orient_txt})", - description=f"Tính tích vô hướng F · dS theo vectơ pháp tuyến định hướng:", + description="Tính tích vô hướng F · dS theo vectơ pháp tuyến định hướng:", content=ExpressionStepContent( expression_before="F . dS", expression_after=str(integrand_2d), diff --git a/src/math_app/solvers/calculus/limit/solver.py b/src/math_app/solvers/calculus/limit/solver.py index 25fc0c2..51a3cb3 100644 --- a/src/math_app/solvers/calculus/limit/solver.py +++ b/src/math_app/solvers/calculus/limit/solver.py @@ -8,7 +8,7 @@ from math_app.application.dtos import ProblemRequest, SolutionResponse, SolutionStep from math_app.application.ports.solver import Solver from math_app.domain.exceptions import InvalidInputError -from math_app.domain.models import ProblemType, SolutionStatus, VerificationStatus +from math_app.domain.models import ProblemType, SolutionStatus from math_app.domain.step_content import ExpressionStepContent from math_app.input.parser import parse_mathematical_input, parse_safe_expression from math_app.rendering.latex.formatter import expr_to_latex diff --git a/src/math_app/solvers/calculus/pedagogical_derivation.py b/src/math_app/solvers/calculus/pedagogical_derivation.py index 2fdca63..3e45845 100644 --- a/src/math_app/solvers/calculus/pedagogical_derivation.py +++ b/src/math_app/solvers/calculus/pedagogical_derivation.py @@ -81,7 +81,7 @@ def analyze_term_diff(t: sp.Expr, var: sp.Symbol) -> dict[str, Any]: "c_abs": c_abs, "raw_step": "0", "result_expr": sp.Integer(0), - "explanation": f"đạo hàm của hằng số bằng 0:", + "explanation": "đạo hàm của hằng số bằng 0:", "equation_latex": f"\\frac{{d}}{{d{var.name}}}({expr_to_latex(t_clean)}) = 0", "rule_name": "Constant Rule", "rule_name_vi": "Quy tắc đạo hàm của hằng số", @@ -272,10 +272,9 @@ def analyze_term_int(t: sp.Expr, var: sp.Symbol) -> dict[str, Any]: # 1. Pure constant if not mag.has(var): - int_mag = mag * var simp = coeff * var expl = ( - f"nguyên hàm của hằng số âm:" + "nguyên hàm của hằng số âm:" if is_neg else "nguyên hàm của hằng số:" ) @@ -302,7 +301,7 @@ def analyze_term_int(t: sp.Expr, var: sp.Symbol) -> dict[str, Any]: else ( f"đưa hệ số {expr_to_latex(c_abs)} ra ngoài, áp dụng quy tắc nguyên hàm của lũy thừa:" if c_abs != 1 - else f"áp dụng quy tắc nguyên hàm của lũy thừa với số mũ 1:" + else "áp dụng quy tắc nguyên hàm của lũy thừa với số mũ 1:" ) ) eq_lhs = f"\\int ({expr_to_latex(t_clean)})\\,d{var.name}" if is_neg else f"\\int {expr_to_latex(t_clean)}\\,d{var.name}" diff --git a/src/math_app/solvers/linear_algebra/solver.py b/src/math_app/solvers/linear_algebra/solver.py index 623301a..5352e7c 100644 --- a/src/math_app/solvers/linear_algebra/solver.py +++ b/src/math_app/solvers/linear_algebra/solver.py @@ -2,7 +2,6 @@ from __future__ import annotations -import re import time import sympy as sp @@ -132,14 +131,14 @@ def solve(self, request: ProblemRequest) -> SolutionResponse: if det_val == 0: raise InvalidInputError( - f"Ma trận suy biến: det(A) = 0 nên ma trận không có ma trận nghịch đảo." + "Ma trận suy biến: det(A) = 0 nên ma trận không có ma trận nghịch đảo." ) inv_mat = A.inv() builder.add_step( rule_name="Ma trận nghịch đảo", title="Xác định ma trận nghịch đảo", - description=f"Áp dụng công thức A^(-1) = (1 / det(A)) · adj(A) ta được ma trận nghịch đảo.", + description="Áp dụng công thức A^(-1) = (1 / det(A)) · adj(A) ta được ma trận nghịch đảo.", before=A, after=inv_mat, ) @@ -401,7 +400,7 @@ def solve(self, request: ProblemRequest) -> SolutionResponse: x_sol = A.LUsolve(b) if (A.rows == A.cols and A.det() != 0) else A.gauss_jordan_solve(b)[0] builder.add_step( rule_name="Giải hệ phương trình bằng phép khử Gauss-Jordan", - description=f"Tìm được nghiệm duy nhất x cho hệ A * x = b.", + description="Tìm được nghiệm duy nhất x cho hệ A * x = b.", before=aug, after=x_sol, ) diff --git a/src/math_app/verification/calculus/integral_verifier.py b/src/math_app/verification/calculus/integral_verifier.py index ca3dd99..2acfa3b 100644 --- a/src/math_app/verification/calculus/integral_verifier.py +++ b/src/math_app/verification/calculus/integral_verifier.py @@ -107,6 +107,12 @@ def verify_definite( method="numerical_quadrature_audit", details="Kết quả số học khớp với tích phân số độc lập trong sai số cho phép.", ) + else: + return VerificationResult( + status=VerificationStatus.FAILED, + method="numerical_quadrature_discrepancy", + details=f"Phát hiện sai lệch số học giữa kết quả tính toán ({val_comp.real:.6g}) và tích phân độc lập ({val_ref.real:.6g}).", + ) except Exception: pass diff --git a/src/math_app/verification/calculus/surface_area_verifier.py b/src/math_app/verification/calculus/surface_area_verifier.py index e4d23a2..8e8ceb7 100644 --- a/src/math_app/verification/calculus/surface_area_verifier.py +++ b/src/math_app/verification/calculus/surface_area_verifier.py @@ -67,6 +67,12 @@ def verify_surface_area( method="numerical_2d_gauss_quadrature_surface_area", details="Xác nhận số học bằng cầu phương Gauss 2D khớp với diện tích mặt tính toán.", ) + else: + return VerificationResult( + status=VerificationStatus.FAILED, + method="numerical_2d_gauss_quadrature_discrepancy", + details=f"Phát hiện sai lệch giữa diện tích tính toán ({comp_f:.6g}) và cầu phương Gauss 2D ({num_val:.6g}).", + ) except Exception: pass diff --git a/src/math_app/verification/calculus/surface_integral_verifier.py b/src/math_app/verification/calculus/surface_integral_verifier.py index 2b2a905..63e3255 100644 --- a/src/math_app/verification/calculus/surface_integral_verifier.py +++ b/src/math_app/verification/calculus/surface_integral_verifier.py @@ -2,7 +2,6 @@ from __future__ import annotations -import math import sympy as sp from math_app.application.dtos import VerificationResult diff --git a/tests/golden/test_unified_pedagogical_derivation.py b/tests/golden/test_unified_pedagogical_derivation.py index 398e432..8ab8566 100644 --- a/tests/golden/test_unified_pedagogical_derivation.py +++ b/tests/golden/test_unified_pedagogical_derivation.py @@ -1,7 +1,6 @@ """Golden acceptance tests for unified, continuous pedagogical mathematical derivations.""" import sympy as sp -import pytest from math_app.application.dtos import ProblemRequest from math_app.domain.models import ProblemType, SolutionStatus, VerificationStatus diff --git a/tests/unit/application/test_solve_problem.py b/tests/unit/application/test_solve_problem.py index d0b52b4..9f38fbd 100644 --- a/tests/unit/application/test_solve_problem.py +++ b/tests/unit/application/test_solve_problem.py @@ -23,6 +23,9 @@ def solve(self, request: ProblemRequest) -> SolutionResponse: raise InvalidInputError("Triggered syntax error.") if "crash" in request.raw_input: raise RuntimeError("Unexpected failure.") + if "slow" in request.raw_input: + import time + time.sleep(5) return SolutionResponse( status=SolutionStatus.SUCCESS, @@ -127,3 +130,47 @@ def test_use_case_handles_unexpected_exception(): assert response.status == SolutionStatus.CANNOT_SOLVE assert "unexpected failure" in response.warnings[0] + + +def test_use_case_hard_timeout_and_subsequent_recovery(): + """Verify that a hung computation is killed on timeout and subsequent calculations succeed.""" + registry = DefaultSolverRegistry() + registry.register(ProblemType.DERIVATIVE, DummyDerivativeSolver()) + use_case = SolveProblemUseCase(registry) + + # 1. Computation that exceeds timeout + slow_request = ProblemRequest( + raw_input="slow", + problem_type=ProblemType.DERIVATIVE, + ) + slow_response = use_case.execute(slow_request, timeout_seconds=0.4) + + assert slow_response.status == SolutionStatus.TIMEOUT + assert slow_response.verification.status == VerificationStatus.FAILED + assert "vượt quá thời gian" in slow_response.warnings[0] + + # 2. Subsequent computation must execute and succeed cleanly + valid_request = ProblemRequest( + raw_input="x^2", + problem_type=ProblemType.DERIVATIVE, + ) + valid_response = use_case.execute(valid_request, timeout_seconds=3.0) + + assert valid_response.status == SolutionStatus.SUCCESS + assert valid_response.final_result == "2*x" + + +def test_use_case_solve_equation_integration(): + """Verify that build_application() dispatches SOLVE_EQUATION end-to-end.""" + from math_app.main import build_application + app_use_case = build_application() + + req = ProblemRequest( + raw_input="x^2 - 9 = 0", + problem_type=ProblemType.SOLVE_EQUATION, + target_variable="x", + ) + res = app_use_case.execute(req) + assert res.status == SolutionStatus.SUCCESS + assert "-3" in res.final_result and "3" in res.final_result + assert res.verification.status == VerificationStatus.VERIFIED diff --git a/tests/unit/domain/test_dtos.py b/tests/unit/domain/test_dtos.py index 9edc257..9b5fbd3 100644 --- a/tests/unit/domain/test_dtos.py +++ b/tests/unit/domain/test_dtos.py @@ -4,12 +4,11 @@ import pytest from math_app.application.dtos import ( - ProblemRequest, SolutionResponse, SolutionStep, VerificationResult, ) -from math_app.domain.models import ProblemType, SolutionStatus, VerificationStatus +from math_app.domain.models import SolutionStatus, VerificationStatus from math_app.domain.step_content import ExpressionStepContent diff --git a/tests/unit/input/test_security_remediation.py b/tests/unit/input/test_security_remediation.py index 3ec4637..8bab4eb 100644 --- a/tests/unit/input/test_security_remediation.py +++ b/tests/unit/input/test_security_remediation.py @@ -18,7 +18,6 @@ ) from math_app.solvers.calculus.geometry.arc_length_solver import ArcLengthSolver from math_app.solvers.calculus.geometry.surface_area_solver import SurfaceAreaSolver -from math_app.solvers.calculus.geometry.surface_of_revolution_solver import SurfaceOfRevolutionSolver from math_app.solvers.calculus.integration.solver import IntegralSolver from math_app.solvers.calculus.limit.solver import LimitSolver from math_app.solvers.calculus.vector_calculus.divergence_theorem_solver import DivergenceTheoremSolver @@ -140,6 +139,33 @@ def test_resource_bounds_length_depth_and_exponent(): parse_safe_expression(huge_exp) +def test_denial_of_service_nested_and_excessive_exponents_rejected(): + """Verify adversarial payloads such as x**(9**9**9) and 9**9**9**9 are rejected immediately.""" + adversarial_payloads = [ + "x**(9**9**9)", + "9**9**9**9", + "x^(9^9^9)", + "9^9^9^9", + "x**(2**3)", + "x**(y**z)", + "9**9", + "x**(-100)", + "x**(10*10)", + "x**(5*11)", + "100000000", + ] + for payload in adversarial_payloads: + with pytest.raises(InvalidInputError): + parse_safe_expression(payload) + with pytest.raises(InvalidInputError): + parse_mathematical_input(payload, "x") + + # Excessive AST node count (under 200 chars but > 100 AST nodes) + oversized_node_expr = "x+" * 50 + "x" # 101 chars, 102 AST nodes + with pytest.raises(InvalidInputError, match="giới hạn cho phép|toán tử"): + parse_safe_expression(oversized_node_expr) + + def test_solvers_reject_unsafe_parameter_inputs(): """Verify solvers reject malicious payloads passed via request parameters or bounds.""" # Limit solver point diff --git a/tests/unit/presentation/test_branding_and_footer.py b/tests/unit/presentation/test_branding_and_footer.py index 27a69e5..804a43c 100644 --- a/tests/unit/presentation/test_branding_and_footer.py +++ b/tests/unit/presentation/test_branding_and_footer.py @@ -1,9 +1,7 @@ """Unit and regression tests for MathWeave Studio application branding, metadata, and footer.""" -import pytest from nicegui import ui -import math_app from math_app import ( __author__, __compact_name__, diff --git a/tests/unit/presentation/test_comprehensive_fixes_regression.py b/tests/unit/presentation/test_comprehensive_fixes_regression.py index 573c4bf..5868cb4 100644 --- a/tests/unit/presentation/test_comprehensive_fixes_regression.py +++ b/tests/unit/presentation/test_comprehensive_fixes_regression.py @@ -6,11 +6,9 @@ from math_app.application.dtos import SolutionStep from math_app.domain.models import ProblemType from math_app.domain.step_content import ExpressionStepContent -from math_app.input.parser import parse_mathematical_input from math_app.presentation.components.domain_view import format_domain_condition from math_app.presentation.components.math_view import math_html from math_app.presentation.components.operation_catalog import OPERATIONS, get_operation_by_type -from math_app.presentation.components.problem_statement import format_problem_statement_latex from math_app.presentation.components.variation_table import _build_variation_table_html from math_app.presentation.services.analysis_service import ( compute_variation_table, diff --git a/tests/unit/presentation/test_full_multivariable_pipeline.py b/tests/unit/presentation/test_full_multivariable_pipeline.py index 8114b3c..bd3b065 100644 --- a/tests/unit/presentation/test_full_multivariable_pipeline.py +++ b/tests/unit/presentation/test_full_multivariable_pipeline.py @@ -2,7 +2,7 @@ import pytest import sympy as sp -from math_app.application.dtos import ProblemRequest, SolutionResponse +from math_app.application.dtos import ProblemRequest from math_app.application.use_cases.solve_problem import SolveProblemUseCase from math_app.domain.models import ProblemType, SolutionStatus, VerificationStatus from math_app.main import build_application diff --git a/tests/unit/presentation/test_integration_and_3d_axes_regression.py b/tests/unit/presentation/test_integration_and_3d_axes_regression.py index aa1cc9f..1145272 100644 --- a/tests/unit/presentation/test_integration_and_3d_axes_regression.py +++ b/tests/unit/presentation/test_integration_and_3d_axes_regression.py @@ -3,30 +3,21 @@ from __future__ import annotations import numpy as np -import plotly.graph_objects as go import pytest -import sympy as sp -from math_app.application.dtos import ProblemRequest -from math_app.application.use_cases.solve_problem import SolveProblemUseCase -from math_app.domain.models import ProblemType, SolutionStatus +from math_app.domain.models import ProblemType from math_app.presentation.components.operation_catalog import ( MAIN_OPERATIONS, - OPERATIONS, get_operation_by_type, ) from math_app.presentation.components.problem_statement import ( format_problem_statement_latex, - get_result_card_title, ) -from math_app.presentation.components.solution_view import determine_contextual_tools from math_app.presentation.services.plot_3d_service import ( - build_3d_figure, create_parametric_surface_plot, create_surface_plot_from_expression, - Plot3DConfig, ) -from math_app.presentation.services.analysis_service import can_plot_3d, create_3d_plot +from math_app.presentation.services.analysis_service import create_3d_plot # ============================================================================== diff --git a/tests/unit/presentation/test_operation_catalog.py b/tests/unit/presentation/test_operation_catalog.py index 8a9c0b1..19ef907 100644 --- a/tests/unit/presentation/test_operation_catalog.py +++ b/tests/unit/presentation/test_operation_catalog.py @@ -65,3 +65,10 @@ def test_rref_card_configured(): rref_op = get_operation_by_type(ProblemType.MATRIX_RREF) assert rref_op.title == "Dạng bậc thang rút gọn (RREF)" assert "Gauss" in rref_op.description or "bậc thang rút gọn" in rref_op.description + + +def test_solve_equation_card_configured(): + op = get_operation_by_type(ProblemType.SOLVE_EQUATION) + assert op.title == "Giải phương trình" + assert op.problem_type == ProblemType.SOLVE_EQUATION + assert op.is_calculus is True diff --git a/tests/unit/presentation/test_shutdown_lifecycle.py b/tests/unit/presentation/test_shutdown_lifecycle.py index 2a06fae..557a1fe 100644 --- a/tests/unit/presentation/test_shutdown_lifecycle.py +++ b/tests/unit/presentation/test_shutdown_lifecycle.py @@ -1,6 +1,5 @@ """Tests for clean application lifecycle and bounded shutdown.""" -import os import signal import subprocess import sys diff --git a/tests/unit/presentation/test_solution_view_rendering.py b/tests/unit/presentation/test_solution_view_rendering.py index 265473c..7711db9 100644 --- a/tests/unit/presentation/test_solution_view_rendering.py +++ b/tests/unit/presentation/test_solution_view_rendering.py @@ -1,6 +1,5 @@ """Tests exercising solution view fallback branches and plotting callbacks.""" -import pytest from nicegui import ui from math_app.application.dtos import SolutionResponse, VerificationResult @@ -76,3 +75,36 @@ def test_solution_view_parametric_surface_rendering(): } with ui.card(): render_solution_view(response, op, "u", params) + + +def test_solution_view_timeout_card_rendering(): + """Verify SolutionStatus.TIMEOUT renders informative timeout alert card.""" + op = get_operation_by_type(ProblemType.DERIVATIVE) + response = SolutionResponse( + status=SolutionStatus.TIMEOUT, + verification=VerificationResult(VerificationStatus.FAILED, "timeout", "Timed out"), + raw_input="slow_computation", + canonical_input="slow_computation", + final_result="", + final_latex="", + warnings=("Quá trình giải toán vượt quá thời gian cho phép (8.0 giây).",), + steps=(), + ) + with ui.card(): + render_solution_view(response, op, "x", {}) + + +def test_solution_view_equation_solving_rendering(): + """Verify solution view renders equation solving result properly.""" + op = get_operation_by_type(ProblemType.SOLVE_EQUATION) + response = SolutionResponse( + status=SolutionStatus.SUCCESS, + verification=VerificationResult(VerificationStatus.VERIFIED, "algebraic_root_substitution", "Verified"), + raw_input="x^2 - 4 = 0", + canonical_input="x^2 - 4 = 0", + final_result="x = -2, x = 2", + final_latex="x = -2, \\quad x = 2", + steps=(), + ) + with ui.card(): + render_solution_view(response, op, "x", {}) diff --git a/tests/unit/presentation/test_submission_pipeline_regression.py b/tests/unit/presentation/test_submission_pipeline_regression.py index 2ac8b59..fc8da72 100644 --- a/tests/unit/presentation/test_submission_pipeline_regression.py +++ b/tests/unit/presentation/test_submission_pipeline_regression.py @@ -14,7 +14,6 @@ from math_app.main import build_application from math_app.presentation.components.operation_catalog import ( OPERATIONS, - OperationDef, get_operation_by_type, ) from math_app.presentation.components.problem_statement import ( diff --git a/tests/unit/presentation/test_ui_rendering_regression.py b/tests/unit/presentation/test_ui_rendering_regression.py index 4b9009b..2e6e5dd 100644 --- a/tests/unit/presentation/test_ui_rendering_regression.py +++ b/tests/unit/presentation/test_ui_rendering_regression.py @@ -1,7 +1,5 @@ """Regression tests for mathematical rendering, layout, and absence of input/spinner controls.""" -import re -import pytest from math_app.domain.models import ProblemType, VerificationStatus from math_app.presentation.components.math_view import math_html @@ -10,7 +8,6 @@ format_final_answer_latex, format_problem_statement_latex, ) -from math_app.presentation.components.verification_badge import render_verification_badge from math_app.application.dtos import VerificationResult @@ -138,3 +135,23 @@ def test_no_successful_verification_badge_when_result_is_empty(): # Note: render_verification_badge requires nicegui context if rendered, # but the rule enforces has_valid_result=False never emits positive badge assert result.status == VerificationStatus.VERIFIED + + +def test_math_html_sanitizes_and_escapes_malicious_html(): + """Verify script tags, event handlers, and fallback rendering do not emit unescaped HTML.""" + payloads = [ + "", + "", + "", + "javascript:alert(1)", + "click me", + ] + for p in payloads: + out = math_html(p) + # Raw unescaped dangerous tags must never be present + assert " EquationSolver: + return EquationSolver() + + +def test_can_solve(solver: EquationSolver): + assert solver.can_solve(ProblemRequest("x = 0", ProblemType.SOLVE_EQUATION)) + assert not solver.can_solve(ProblemRequest("x", ProblemType.DERIVATIVE)) + + +def test_solve_linear_equation(solver: EquationSolver): + req = ProblemRequest(raw_input="2*x + 4 = 0", problem_type=ProblemType.SOLVE_EQUATION, target_variable="x") + res = solver.solve(req) + + assert res.status == SolutionStatus.SUCCESS + assert "x = -2" in res.final_result + assert res.verification.status == VerificationStatus.VERIFIED + assert len(res.steps) >= 2 + + +def test_solve_quadratic_multiple_solutions(solver: EquationSolver): + req = ProblemRequest(raw_input="x^2 - 5*x + 6 = 0", problem_type=ProblemType.SOLVE_EQUATION, target_variable="x") + res = solver.solve(req) + + assert res.status == SolutionStatus.SUCCESS + assert "2" in res.final_result and "3" in res.final_result + assert res.verification.status == VerificationStatus.VERIFIED + + +def test_solve_implicit_zero_rhs(solver: EquationSolver): + req = ProblemRequest(raw_input="3*x - 9", problem_type=ProblemType.SOLVE_EQUATION, target_variable="x") + res = solver.solve(req) + + assert res.status == SolutionStatus.SUCCESS + assert "x = 3" in res.final_result + assert res.verification.status == VerificationStatus.VERIFIED + + +def test_solve_no_solution_inconsistent(solver: EquationSolver): + req = ProblemRequest(raw_input="x + 1 = x", problem_type=ProblemType.SOLVE_EQUATION, target_variable="x") + res = solver.solve(req) + + assert res.status == SolutionStatus.SUCCESS + assert "Vô nghiệm" in res.final_result + assert res.verification.status == VerificationStatus.VERIFIED + assert res.final_latex == r"S = \emptyset" + + +def test_solve_infinitely_many_solutions(solver: EquationSolver): + req = ProblemRequest(raw_input="2*x = 2*x", problem_type=ProblemType.SOLVE_EQUATION, target_variable="x") + res = solver.solve(req) + + assert res.status == SolutionStatus.SUCCESS + assert "Vô số nghiệm" in res.final_result + assert res.verification.status == VerificationStatus.VERIFIED + assert res.final_latex == r"S = \mathbb{R}" + + +def test_solve_malformed_inputs(solver: EquationSolver): + # Empty + with pytest.raises(InvalidInputError): + solver.solve(ProblemRequest(raw_input="", problem_type=ProblemType.SOLVE_EQUATION)) + + # Multiple '=' + with pytest.raises(InvalidInputError, match="đúng một dấu"): + solver.solve(ProblemRequest(raw_input="x = 2 = 3", problem_type=ProblemType.SOLVE_EQUATION)) + + # Empty side + with pytest.raises(InvalidInputError, match="không được để trống"): + solver.solve(ProblemRequest(raw_input="x =", problem_type=ProblemType.SOLVE_EQUATION)) diff --git a/tests/unit/solvers/calculus/test_derivation_regression.py b/tests/unit/solvers/calculus/test_derivation_regression.py index ae6e97b..a3bfabc 100644 --- a/tests/unit/solvers/calculus/test_derivation_regression.py +++ b/tests/unit/solvers/calculus/test_derivation_regression.py @@ -1,7 +1,6 @@ """Regression tests verifying structured Vietnamese derivations, rules, and mathematical correctness.""" import sympy as sp -import pytest from math_app.application.dtos import ProblemRequest from math_app.domain.models import ProblemType, SolutionStatus, VerificationStatus @@ -98,7 +97,7 @@ def test_unsupported_rule_limitations_labeled_honestly(): """Unsupported operations should not fabricate steps, and must state limitation honestly in Vietnamese.""" from math_app.solvers.calculus.differentiation.rule_engine import DifferentiationRuleEngine from math_app.solvers.calculus.differentiation.step_builder import DifferentiationStepBuilder - + # Empty engine without rules forces fallback engine = DifferentiationRuleEngine(rules=[]) builder = DifferentiationStepBuilder() @@ -106,6 +105,7 @@ def test_unsupported_rule_limitations_labeled_honestly(): expr = sp.besselj(0, x) # Special function not in standard curriculum rules res = engine.differentiate(expr, x, builder) + assert res is not None assert engine.had_fallback is True steps = builder.get_steps() diff --git a/tests/unit/solvers/calculus/test_limit_solver.py b/tests/unit/solvers/calculus/test_limit_solver.py index d60647f..29f9e6e 100644 --- a/tests/unit/solvers/calculus/test_limit_solver.py +++ b/tests/unit/solvers/calculus/test_limit_solver.py @@ -1,6 +1,5 @@ """Unit tests for calculus limit solver and verifier.""" -import pytest from math_app.application.dtos import ProblemRequest from math_app.domain.models import ProblemType, SolutionStatus, VerificationStatus diff --git a/tests/unit/solvers/calculus/test_rules.py b/tests/unit/solvers/calculus/test_rules.py index cd3544e..61aa035 100644 --- a/tests/unit/solvers/calculus/test_rules.py +++ b/tests/unit/solvers/calculus/test_rules.py @@ -12,7 +12,6 @@ from math_app.solvers.calculus.differentiation.rules.product_rule import ProductRule from math_app.solvers.calculus.differentiation.rules.quotient_rule import QuotientRule from math_app.solvers.calculus.differentiation.rules.sum_rule import SumRule -from math_app.solvers.calculus.differentiation.rules.trig_rule import TrigRule from math_app.solvers.calculus.differentiation.step_builder import DifferentiationStepBuilder diff --git a/tests/unit/verification/test_integral_verifier.py b/tests/unit/verification/test_integral_verifier.py new file mode 100644 index 0000000..df66acc --- /dev/null +++ b/tests/unit/verification/test_integral_verifier.py @@ -0,0 +1,81 @@ +"""Unit tests for independent integral verification.""" + +import sympy as sp +from math_app.domain.models import VerificationStatus +from math_app.verification.calculus.integral_verifier import IntegralVerifier + + +def test_indefinite_ftc_symbolic_verified(): + verifier = IntegralVerifier() + x = sp.Symbol("x") + + # Correct antiderivative of x^2 is x^3 / 3 + integrand = x ** 2 + antiderivative = (x ** 3) / 3 + result = verifier.verify_indefinite(integrand, antiderivative, x) + + assert result.status == VerificationStatus.VERIFIED + assert result.method == "fundamental_theorem_of_calculus" + + +def test_indefinite_ftc_detects_wrong_antiderivative(): + verifier = IntegralVerifier() + x = sp.Symbol("x") + + # Deliberately incorrect antiderivative + integrand = x ** 2 + wrong_antiderivative = x ** 2 + result = verifier.verify_indefinite(integrand, wrong_antiderivative, x) + + assert result.status == VerificationStatus.FAILED + assert result.method == "numerical_ftc_spot_check" + + +def test_indefinite_ftc_numerical_spot_check_verified(): + verifier = IntegralVerifier() + x = sp.Symbol("x") + + # Integrand whose difference with derived might not immediately simplify to 0 without expansion + integrand = sp.sin(x) * sp.cos(x) + antiderivative = (sp.sin(x) ** 2) / 2 + result = verifier.verify_indefinite(integrand, antiderivative, x) + + assert result.status == VerificationStatus.VERIFIED + + +def test_definite_symbolic_verified(): + verifier = IntegralVerifier() + x = sp.Symbol("x") + + # Definite integral of x from 0 to 2 is 2 + integrand = x + computed = sp.Integer(2) + result = verifier.verify_definite(integrand, computed, x, sp.Integer(0), sp.Integer(2)) + + assert result.status == VerificationStatus.VERIFIED + assert result.method == "symbolic_definite_integration_check" + + +def test_definite_detects_wrong_computed_result(): + verifier = IntegralVerifier() + x = sp.Symbol("x") + + # Deliberately incorrect definite value (expected 2, provided 99) + integrand = x + wrong_computed = sp.Integer(99) + result = verifier.verify_definite(integrand, wrong_computed, x, sp.Integer(0), sp.Integer(2)) + + assert result.status == VerificationStatus.FAILED + assert "discrepancy" in result.method.lower() + + +def test_definite_numerical_quadrature_verified(): + verifier = IntegralVerifier() + x = sp.Symbol("x") + + # Known analytical value: integral of exp(-x) from 0 to 1 = 1 - exp(-1) + integrand = sp.exp(-x) + computed = 1 - sp.exp(-1) + result = verifier.verify_definite(integrand, computed, x, sp.Integer(0), sp.Integer(1)) + + assert result.status == VerificationStatus.VERIFIED diff --git a/tests/unit/verification/test_surface_area_verifier.py b/tests/unit/verification/test_surface_area_verifier.py new file mode 100644 index 0000000..de5338f --- /dev/null +++ b/tests/unit/verification/test_surface_area_verifier.py @@ -0,0 +1,76 @@ +"""Unit tests for independent surface area verification.""" + +import sympy as sp +from math_app.domain.models import VerificationStatus +from math_app.verification.calculus.surface_area_verifier import SurfaceAreaVerifier + + +def test_surface_area_symbolic_verified(): + verifier = SurfaceAreaVerifier() + u, v = sp.symbols("u v") + + # Flat plane z = 0 over [0, 2] x [0, 3], area = 6 + integrand = sp.Integer(1) + computed_area = sp.Integer(6) + + result = verifier.verify_surface_area( + computed_area=computed_area, + integrand=integrand, + u_var=u, + u_min=sp.Integer(0), + u_max=sp.Integer(2), + v_var=v, + v_min=sp.Integer(0), + v_max=sp.Integer(3), + ) + + assert result.status == VerificationStatus.VERIFIED + assert result.method == "symbolic_surface_area_integral" + + +def test_surface_area_detects_wrong_computed_area(): + verifier = SurfaceAreaVerifier() + u, v = sp.symbols("u v") + + # Deliberately wrong computed area (expected 6, provided 100) + integrand = sp.Integer(1) + wrong_area = sp.Integer(100) + + result = verifier.verify_surface_area( + computed_area=wrong_area, + integrand=integrand, + u_var=u, + u_min=sp.Integer(0), + u_max=sp.Integer(2), + v_var=v, + v_min=sp.Integer(0), + v_max=sp.Integer(3), + ) + + assert result.status == VerificationStatus.FAILED + assert "discrepancy" in result.method.lower() + + +def test_surface_area_gauss_quadrature_verified(): + verifier = SurfaceAreaVerifier() + u, v = sp.symbols("u v") + + # Non-trivial surface integrand sqrt(1 + 4*u^2 + 4*v^2) for paraboloid + # For a small region [0, 0.5] x [0, 0.5] + integrand = sp.sqrt(1 + 4 * u**2 + 4 * v**2) + # Compute exact analytical integral via sympy + inner = sp.integrate(integrand, (u, 0, sp.Rational(1, 2))) + exact_area = sp.integrate(inner, (v, 0, sp.Rational(1, 2))) + + result = verifier.verify_surface_area( + computed_area=exact_area, + integrand=integrand, + u_var=u, + u_min=sp.Integer(0), + u_max=sp.Rational(1, 2), + v_var=v, + v_min=sp.Integer(0), + v_max=sp.Rational(1, 2), + ) + + assert result.status == VerificationStatus.VERIFIED diff --git a/uv.lock b/uv.lock index 725fbf0..e9b2b91 100644 --- a/uv.lock +++ b/uv.lock @@ -201,11 +201,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.7.22" +version = "2026.10.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, ] [[package]] @@ -265,6 +265,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/79/e78bd8f75a9b8e32233ce92d4607f0a21ce88a9156cace2884e7175a82f5/coverage-7.17.0.tar.gz", hash = "sha256:cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712", size = 991021, upload-time = "2026-10-22T16:06:50.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/e1/722462fe79912ebebc101497bd16c42413cde2dbae771017f1ec0acb84c2/coverage-7.17.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:dc5936491e306f2f6f095bd5fba593de11eb0375cee787ca1f245c3a333a523a", size = 227197, upload-time = "2026-10-22T16:05:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/b7/74/826b9c09f2ebfea803bccb391d935688a25309bff8e819fc446bc629c108/coverage-7.17.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:968c5fb6e0c83cab752d79a70209648ce4d90cae815c60fa229b59e264601062", size = 227314, upload-time = "2026-10-22T16:05:11.422Z" }, + { url = "https://files.pythonhosted.org/packages/6d/19/c8514d7f5e0659a96ff2bc7377119dca6d420e2761c5fc69ae37618734f8/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:12cb8a20ad904a5811e6660d829a586b216e5254b9b34b76dde8a4d65d827dc6", size = 225477, upload-time = "2026-10-22T16:05:12.761Z" }, + { url = "https://files.pythonhosted.org/packages/81/74/ef9433ce599f07d8633b4ce6e6d6921505c4ef3f7f9006f8eb66e2c278cf/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bb5ca66937c55bc85d1a5e9c5900a3e4693a8edf2280a9b3672b7b2661a84b95", size = 225787, upload-time = "2026-10-22T16:05:14.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/26e58981940e8b3944c6a9941bec5c7f50840e7009a046712e3f3bcd9e6a/coverage-7.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5cc15faaf318ee5040d835a62c75bc6f122d837274f3b5efd47bf9fbd4ac5b85", size = 226019, upload-time = "2026-10-22T16:05:15.798Z" }, + { url = "https://files.pythonhosted.org/packages/e9/63/fe175310815cc8357b83b773c9b4c20943c269f217dd825a37c4b4bfe324/coverage-7.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d6d9d396c8b7bcaf76d774a10140b218111484e54c73a5f2a1b75655381748e", size = 226169, upload-time = "2026-10-22T16:05:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/2c/b0/ac84761f5b4bd226696b0ec4606903d2ef7208abda0aec06c14a4218def4/coverage-7.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:42e6f170d1ac04b7eb3363310a6c41cdfd82c1fe6bf9ffbc4a86f380bdf091c7", size = 258092, upload-time = "2026-10-22T16:05:18.691Z" }, + { url = "https://files.pythonhosted.org/packages/e9/58/d8bdd65a661913bc12eaaa3e59742b2cb8b94f2e7f78e62184f0a58ca493/coverage-7.17.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:acdb5f3c96046efff9dc6c572a6acb2b8d0da09372d7b9a9ed7249cf7d17434f", size = 261096, upload-time = "2026-10-22T16:05:20.089Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/5d82e2f0a75ea149e0a6412e137e1d610538772fdfd7199737db7fe234da/coverage-7.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3393209f6894713161ee82a9b0e673e58237e0f9733c6638e7a6c0c620ef0b33", size = 262080, upload-time = "2026-10-22T16:05:21.568Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cc/e3ebfd295d1dd47c7bc84a17d73f150465a37ab388dd68fe82b495dc79a0/coverage-7.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9494d9e99389d39e6a96227749af898d8924ff591cb63944f8a98e68582bf0e", size = 264558, upload-time = "2026-10-22T16:05:22.906Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9d/e2adff76d6544bbc6df7366d74967923c2de37ba889a006bfb875bf5857b/coverage-7.17.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:032d4c2db4c677eb71d43f2f8b80bf64e9346f7a3f7b6ebb13f1782f3cc6c274", size = 257855, upload-time = "2026-10-22T16:05:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fd/ecd1e9cddf1c36b5d03c438568b1f53e63db84bb3656539d71b454484c7d/coverage-7.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4343a9378b964eb3cb80fa90406385de91c7e982a769ca9314d848f93bc0d3f2", size = 259933, upload-time = "2026-10-22T16:05:25.79Z" }, + { url = "https://files.pythonhosted.org/packages/40/b6/e17a19dccdbcad494c64691f71c1dba3e55c30625bc29ffbb88ac5a0aa8b/coverage-7.17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b609df3d6d900464207532c5170a65c6ede8d2e0b91f151f90008a866395cd76", size = 257972, upload-time = "2026-10-22T16:05:27.185Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0d/37dac534709a3fa43a937c8b0604839bb44b8670b83d143a4b78fec20d1e/coverage-7.17.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a01ef92caeedfa4f2ad171a3a8e6fece4565de871d8bca428151bf9929f41048", size = 262271, upload-time = "2026-10-22T16:05:28.619Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/7c05186925b4d64a46c271dfd944e3f0c1ad89db9095c31a6df157a75df2/coverage-7.17.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a3914e2c98c34b94c26d2add3d2a61ccfd3534fc4eaa879ee290bdbb5184ba6c", size = 257604, upload-time = "2026-10-22T16:05:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/95/65/c241cdee66a0ff86c68f6d37328e735d10e3d30cd6ff66f356039eb688d2/coverage-7.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8a72b26dc5c9a9422e45472b1d67ba5caf273c524c8668cc04b23062663755e", size = 259714, upload-time = "2026-10-22T16:05:31.402Z" }, + { url = "https://files.pythonhosted.org/packages/db/09/e93233fe2405574ce0a31ed201c0cba3a860e085db5d2828bf12b7dc0790/coverage-7.17.0-cp314-cp314-win32.whl", hash = "sha256:03baf0688e14a5a44ffd1eefa8928dfc92b14565e521174e072b31b971aa1657", size = 227914, upload-time = "2026-10-22T16:05:32.78Z" }, + { url = "https://files.pythonhosted.org/packages/d7/77/04979eb217edb94b94afabb4bcc179f60c746f68d3fd0b12688ba9195730/coverage-7.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3a4459b0f389dd1e51aac3dbfc4ca35b30cae7dc99caa3c0a086d1997a985709", size = 228543, upload-time = "2026-10-22T16:05:34.118Z" }, + { url = "https://files.pythonhosted.org/packages/a0/38/155f2f2c6769f8c43eca8a5e581443faa0908828650090b97103b4a41376/coverage-7.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32ee5831b15ba239bef5d272ae586ca8acea4a661b5f2941f5a0d380e518b23", size = 228396, upload-time = "2026-10-22T16:05:35.951Z" }, + { url = "https://files.pythonhosted.org/packages/52/55/d6751572d9445485c4c5a51ec6ae11caa5c11b7ef2448cdccc884f7f54d9/coverage-7.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9c083e253e96fdeeaf48133fe836cac86ace2db215d235700e7f7532420a200c", size = 226486, upload-time = "2026-10-22T16:05:37.363Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a8/82d37760e537bfdb9fdee285fa1e1b45bc153293d23e2331c7befedd1990/coverage-7.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b88fb4d4d7608cbcebdc7ff7dbffc2115e3b81d6c33a79147cd79d54fcb080f4", size = 226861, upload-time = "2026-10-22T16:05:38.777Z" }, + { url = "https://files.pythonhosted.org/packages/24/96/70ec00a9db6d7bdd3f914eba12057360769f5cb78a98456e71fe1937deb5/coverage-7.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2bf3ee38a40c0570c316b9e7507c472469586906a20f289705898528c31aaea8", size = 266849, upload-time = "2026-10-22T16:05:40.285Z" }, + { url = "https://files.pythonhosted.org/packages/77/d8/d99be3bfbb208512f039c80b31e404138a86678fc929a17acd5b7135a6ae/coverage-7.17.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7c570293f001ea91598b90e90cb9b98fddee833e90bbede432cbd218331ccd8e", size = 269402, upload-time = "2026-10-22T16:05:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/6cbe8b5a5060edbd21ce8109c4e900f8b7656e8b72ede6dc4d82dc0b8bfb/coverage-7.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:791f9ee09814dd30d9562b4f4eb037a371485ec1c830b53999d2320d20c4e764", size = 272025, upload-time = "2026-10-22T16:05:42.921Z" }, + { url = "https://files.pythonhosted.org/packages/bd/05/d3633add01e5518dfdacfdbd1be41cb5c581412dd8bccd663edf6ef7fec8/coverage-7.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7c5672ecf1b497de7a0b37743bef40e3b58e3207e9fe2ed6682cb0420836a88", size = 273515, upload-time = "2026-10-22T16:05:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/57/d9/b83387780271bf9ea9f96a3aeb01c1c302c4494a927923c4fa70cd170a3f/coverage-7.17.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:337f87e62e1c5f43c8198d879f677c342d152f84d3f157add12558853cbb8eaf", size = 266725, upload-time = "2026-10-22T16:05:45.713Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/8e80b29764ae3f4d70d44952eee3f3927eb36897490fe799e400adb7f5da/coverage-7.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c8a58baa8eb6154c6645eadcc19b6c089deab78c24a612a83206f2a8a5992a7c", size = 269312, upload-time = "2026-10-22T16:05:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/e702f588fe8d83ca844f133270ad0a49bf650c8cf260551ff6f11a6b6bbf/coverage-7.17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb5745048ee20f796b0e3ab61831049e749acff188563e2af32f58ede957ac1b", size = 266740, upload-time = "2026-10-22T16:05:48.343Z" }, + { url = "https://files.pythonhosted.org/packages/8a/11/ee13ad14b714d75ef07c490b6e43969574a8b8b8c6d67bc741fcbacbf9c3/coverage-7.17.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:7b5abcae74750302ac4bc77351eaee87c450e4abd3b6225eacde3858e260f8b2", size = 271036, upload-time = "2026-10-22T16:05:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/e2/bc/854e8912c11c54aaf416e74602000589c6efbbe1f251dc90202fdabddcb6/coverage-7.17.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4e153f0bd6509dbf837fd585f489b3328bfd98f2a718a0b15ae63032148b285c", size = 266158, upload-time = "2026-10-22T16:05:51.141Z" }, + { url = "https://files.pythonhosted.org/packages/a8/dd/96432830ddf63bc42654de54eee77a0cd6949cc65769bda06f353323f23a/coverage-7.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e42a835271922fe7bf98c7a05c78d4032f4e52bf5b07fafa1eadfdfc2f0ad22f", size = 268088, upload-time = "2026-10-22T16:05:52.92Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ee/230c6ec065a6629d41cc89323ceb54d37e2650c104f70fc108f4ee7c5051/coverage-7.17.0-cp314-cp314t-win32.whl", hash = "sha256:5900055351ec89fdffb34b27898815106529ded29a9496464c1ebd6313fd40fa", size = 228229, upload-time = "2026-10-22T16:05:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/f0/1d/23eb6aadcb3c00764ff2961c0ff55f5dfa2543856d932b949bbc04fa68e9/coverage-7.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5ee9c8bd13a59e301dc2aefe7aa8384633406808de051b1982775667094eb673", size = 229154, upload-time = "2026-10-22T16:05:55.682Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f1/7df117e8e016b7a3ee459e833b3456f3610505afefd071f8727702d98439/coverage-7.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a9cb9a0fb58e6fff5442969af36094ecd6976f4907f179e33b27e15355d8f74", size = 228726, upload-time = "2026-10-22T16:05:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/f9c24911755f91e6f451ca2e2c1804fbcbe9fecbc3310e341bbbbfa1d85b/coverage-7.17.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:adabc1079ba844b096799ba8638d8df434aca1613f95bb7d5791af320cf05559", size = 227196, upload-time = "2026-10-22T16:05:58.543Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a5/2b665b40c626e6bb40e5e8e23d91170b48b5f2f57186f46b07f2fac42386/coverage-7.17.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:6b4593d2871c4c801656dd5861e759f8f5156febd6b6061b02f6a72d64618b7b", size = 227315, upload-time = "2026-10-22T16:06:00.409Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8c/22b518d61b930b8a4d62905d046c76a03675ef19b466462011dfb16439a3/coverage-7.17.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c2c48a36bb970b7265b196032bfd4237c0b373f2b25ef7012d7e905b5b35c19c", size = 225490, upload-time = "2026-10-22T16:06:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/08/5b/d3b141455944e030cd4987f055f8c84a44a1a0b28d81ae93389f44051691/coverage-7.17.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f8bc2cd4c6aa58be1ca243fb8cddb6c3c42031fe4403991ad9cf2b3baed03d9f", size = 225797, upload-time = "2026-10-22T16:06:03.423Z" }, + { url = "https://files.pythonhosted.org/packages/03/24/aab2156983f7022361352640c583d08f0238e4cd7463eb921172001b4cfe/coverage-7.17.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:cbf0b83ce927de6053279ba61342d9f8892afde1385fcba9144cd05d4023d109", size = 226025, upload-time = "2026-10-22T16:06:04.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/db/94697b9ccf4ec94cdad07bf94e2a21b9702247cb212d2d5f18d2b9c3d0de/coverage-7.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:66efc0fb99cee34626fc17d3adac9c3cd816bfdad563bb1e983c3ef432f566d4", size = 226193, upload-time = "2026-10-22T16:06:06.167Z" }, + { url = "https://files.pythonhosted.org/packages/a0/fd/db1c4ade4219831d590fff5aa5b7d9ceaf84586e8e60da6fff71b6182251/coverage-7.17.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58a304efdf2253fb2ea03d45d0a04cc174a92a0cd5279c28c5bb893ce7410968", size = 258046, upload-time = "2026-10-22T16:06:07.51Z" }, + { url = "https://files.pythonhosted.org/packages/95/ee/84ed138990ed752abd73db4adffc1842d6152017d8e21809cc109a413fda/coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aed06129a9f530a948928260d7d2d81c47261355b0127e3c782251b38958463c", size = 261735, upload-time = "2026-10-22T16:06:08.861Z" }, + { url = "https://files.pythonhosted.org/packages/64/ad/c1e4367bbd1e7d562d6bc979c82b542f2040544e95a8cd995b694caf0e1a/coverage-7.17.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6cdb47a9be2d3313829bed1d296086287682a3ee901ff987b3432b45705b771", size = 262819, upload-time = "2026-10-22T16:06:10.49Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3a/e8e8509b0940403eab15d86f70022790a01fce3e971a161919c544953f7a/coverage-7.17.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:804fbaf81d09b34e2f8b1159d01e211a50713d743d32fd9b2ca6cf0ae507a66f", size = 265194, upload-time = "2026-10-22T16:06:11.847Z" }, + { url = "https://files.pythonhosted.org/packages/1f/77/d64bbdbb734b1059ebcde2b5df567f142a9948079e51af9b0abf8c9ec17b/coverage-7.17.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b2afebb96da9a1bbca142f8cc12320c14e5c215e0a49cded4f191f5a31506a28", size = 258410, upload-time = "2026-10-22T16:06:13.234Z" }, + { url = "https://files.pythonhosted.org/packages/e2/29/7cf81f08a254532c074eb52cfe6b12bb753d08f2997269c9fac8de359541/coverage-7.17.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bd04fd23206be9ad3de13b0b39313094fafa66a1f6baf70f5471c004a9fac47d", size = 260697, upload-time = "2026-10-22T16:06:14.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/56/bacea6dee2d5ccb31ab0cfffcee87ca7efa80ff048840209c8013aeaf02e/coverage-7.17.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:5ebfbc87836529a31d25c2dec1262c38eea852ec1c46c0f690a515a7339d9c5b", size = 258649, upload-time = "2026-10-22T16:06:16.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/97/fd8a6a63740f9ee177c53b59e2f4d0752369bb0fd154086a1e69aa1fb255/coverage-7.17.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:a59c3b470ac3b2432af3598a996affbeb10236cd899a005c1c4e517fcff8b592", size = 262989, upload-time = "2026-10-22T16:06:17.85Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/7cd3d3520d45db3788456452b65446182c08f2a7c276f40c9dd11966be02/coverage-7.17.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:7fef12cec4b96f59e09761cbd4fc9bdf4788062d1ae435b920241fc16c8a9549", size = 257955, upload-time = "2026-10-22T16:06:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b9/80343a55e07d854927a915cc21a52e350ae1db9ead33f0248507b960785f/coverage-7.17.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a14e8d69464da9cbe1dc29e7116dbd37296d1d51a695327c478e250349085089", size = 260512, upload-time = "2026-10-22T16:06:21.055Z" }, + { url = "https://files.pythonhosted.org/packages/f3/32/e4c816f0cb3efdc6392bb276ff88359729f9dec605bdd3c1c7a44776f759/coverage-7.17.0-cp315-cp315-win32.whl", hash = "sha256:9a66074231799d8884d267234d49f60c90b43a945dfe069646b72f3058cd4e67", size = 227913, upload-time = "2026-10-22T16:06:22.407Z" }, + { url = "https://files.pythonhosted.org/packages/43/14/283120142ac281834808584249b89957de997c28573ddd65ae106b622121/coverage-7.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:79cf71e5f9a48877ccfed5baa2c72f45af4feeaa596249e23eb15b473134830e", size = 228532, upload-time = "2026-10-22T16:06:23.756Z" }, + { url = "https://files.pythonhosted.org/packages/b9/bc/83dbc6c3bd58042afaefcb0cfd7d7a2e3076989a2f3d9bc6e6edac819784/coverage-7.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:09e563b49c81cd0a33734e926d7a4a6ef1778d74304cde2dcc3aff8b30ea1036", size = 228393, upload-time = "2026-10-22T16:06:25.163Z" }, + { url = "https://files.pythonhosted.org/packages/00/ed/9463d1cb1766a2570343a372c2871c7fb4d561e5051f029399f4d57ce947/coverage-7.17.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:a5e2e0625dfc789af0b2e10acfc106b352f314fffbd19e6392a7961c8cbe5d9a", size = 226481, upload-time = "2026-10-22T16:06:26.563Z" }, + { url = "https://files.pythonhosted.org/packages/ae/59/6536c7fa87be13236b4184fc395f14d18f622a5b8ef7448a6bfdf5f24ec0/coverage-7.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:194562ab1eff0d85aee8a17ce4c5c0bc1ebf70a629a7145257123156ad100fd6", size = 226876, upload-time = "2026-10-22T16:06:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/b0/bc/4ffae6038f78382c9ab6a4969cb5fc01d50b083d119851085807629abe88/coverage-7.17.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:85682ca89bc4601125dcdff23fa70e6274e13c9ad502fba845e6c274ab2c6569", size = 267241, upload-time = "2026-10-22T16:06:29.356Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/cae4b708b4217d33ebf66b9c57f8910a257346b3eaf054a78d73605ddc4c/coverage-7.17.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0d0fac604a962ff546beb038cba9a5a09d88e6e3f01612fb46f7bd4fe5dc8dca", size = 270079, upload-time = "2026-10-22T16:06:30.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/84/1225ab04e55a6a2e483e903a5eb0f8f081cb5325df833594534aa288d91c/coverage-7.17.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72edd4f9483ae30cd1ba4f230687840ef7b58a16afb1c9e81131fd6d64655f36", size = 272775, upload-time = "2026-10-22T16:06:32.247Z" }, + { url = "https://files.pythonhosted.org/packages/94/ad/567f8dd9246a553aa8753732c41f0e0654e98e9a617d6282e64eae0e6aa8/coverage-7.17.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91e487ad07c511da77b574820141a7c2da09bc2a4b4090b5be2424295d100697", size = 274110, upload-time = "2026-10-22T16:06:33.859Z" }, + { url = "https://files.pythonhosted.org/packages/66/5f/e0ee76895df40601e4aedffb6b8e71ea48e276a629fb4488b92cffffca14/coverage-7.17.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1cabb31531002fef5197de5df3c84730aef01e750e25154b9813a2583435aba2", size = 267775, upload-time = "2026-10-22T16:06:35.193Z" }, + { url = "https://files.pythonhosted.org/packages/19/6d/d19c6e45e940e5b9ba697b7be9a8e63cef73d90bdea45b670a59df466717/coverage-7.17.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c9ac886242bbd50fb35a2242ba79555a99891e657da33f98b7ca968e3488a350", size = 270268, upload-time = "2026-10-22T16:06:36.597Z" }, + { url = "https://files.pythonhosted.org/packages/27/a3/b2731bb7aef2db800b330603675c3fa58a6497a05d5b5f8625f3b6285f68/coverage-7.17.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d45cdc495671fc99268ff3653e0da4244972e9fc0e1fc3ea9de18781ad28963a", size = 267298, upload-time = "2026-10-22T16:06:37.973Z" }, + { url = "https://files.pythonhosted.org/packages/50/75/d0c90a81b6b6607933109914dc8b0ef0a5f4cacf9482ca868ae773ee5d0b/coverage-7.17.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:12961f738d717a046d4bf3f8d1727eb3ebe2ed5478ff96cd6233d4dfc39323d3", size = 271899, upload-time = "2026-10-22T16:06:39.389Z" }, + { url = "https://files.pythonhosted.org/packages/64/1e/f038f3a2614e9ef839a40a184713aca40b8ed79e626ab5cdc388771045df/coverage-7.17.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1ee0a982b0e1230b3edf768a7dd022bd2377303763895327ac62deb5efacee03", size = 267380, upload-time = "2026-10-22T16:06:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/30/8d636e639b2d8b1934128573402a7caf66c8cfbc794fa02061d84f115c34/coverage-7.17.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:460679f53893081edb94eeed011d26be37228ba649b853fd2aed7e4394fb1f35", size = 268993, upload-time = "2026-10-22T16:06:42.361Z" }, + { url = "https://files.pythonhosted.org/packages/73/7a/219f6b391cb0636242d06c390a8add5af91afa3e92afd0380dbedd34f487/coverage-7.17.0-cp315-cp315t-win32.whl", hash = "sha256:600e121e412cd6bdb52ec105b95c6a04da0c9710d1534f6e64a42db667907802", size = 228216, upload-time = "2026-10-22T16:06:43.714Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3f/514d294319c5eac11494394545de209079f33993e14526c220eab29b2a96/coverage-7.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c3e11b81faafb1f063248735c3e2c5c4183fc1db2c7f46782170dd7760dffcbc", size = 229146, upload-time = "2026-10-22T16:06:45.262Z" }, + { url = "https://files.pythonhosted.org/packages/f6/82/c0d8bb0a67df838fc7bc6f4060ffc404fd1154fd18e624d83e6abc41aeef/coverage-7.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d472ee1c64868a917a47f02ffb4ac8c22b477f7589d57a3eb0c58b1ad4f0137c", size = 228716, upload-time = "2026-10-22T16:06:47.097Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/48032919343afa2906112d458aceda2043afdb24a2ee8eb4c60904d8ddf1/coverage-7.17.0-py3-none-any.whl", hash = "sha256:bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d", size = 217383, upload-time = "2026-10-22T16:06:48.471Z" }, +] + [[package]] name = "docutils" version = "0.23" @@ -603,6 +680,8 @@ dependencies = [ dev = [ { name = "pyinstaller" }, { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] @@ -620,6 +699,8 @@ requires-dist = [ dev = [ { name = "pyinstaller", specifier = ">=6.22.3" }, { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "ruff", specifier = ">=0.17.2" }, ] [[package]] @@ -1233,6 +1314,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.4" @@ -1362,6 +1457,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" }, ] +[[package]] +name = "ruff" +version = "0.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/58/690cf56da80c8f2c8bf12042b6017beac2c34fe961f65420bcb3fb7fac38/ruff-0.17.2.tar.gz", hash = "sha256:84d632402d14c23b488f941db23712d6736a013225162062d34558c3f3c780eb", size = 5333400, upload-time = "2026-10-22T18:38:00.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/b8/8b6d69c57e8fe1cc6af1aa7493b72a1a5b5a8afe34694e302025441729d4/ruff-0.17.2-py3-none-linux_armv6l.whl", hash = "sha256:96a65e755d8dba094707fb23ab602111d9a31ec2835b3a6992a1914a8fcb211e", size = 10183857, upload-time = "2026-10-22T18:37:14.956Z" }, + { url = "https://files.pythonhosted.org/packages/e8/61/12cc2ff7b2fa90a268ce1e2162c23aaf76e601d22623f6a31b0eeaa764b0/ruff-0.17.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:39c5b55c960515cdb32be16e69efb5734c589c06a0eb38a22694524a336b889d", size = 10210904, upload-time = "2026-10-22T18:37:17.985Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7c/b8850a8e4ad29f64a827dd6c6902db4a9aa538fb2b9eef4e26084001a3bb/ruff-0.17.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:92a80d2f44584dabb0189b3a6c393e37e525e04942838482010575cb81945be7", size = 9408439, upload-time = "2026-10-22T18:37:20.964Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b6/6a64fae86ca5e5e29d5adc311e5481de726cedf9aee607435a16c31c442a/ruff-0.17.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d0906677d89bbf3cf73cde64b5bbe2ab35df3ae43e49ee2627301e91265bac", size = 10047078, upload-time = "2026-10-22T18:37:23.717Z" }, + { url = "https://files.pythonhosted.org/packages/10/19/23d376547715175aa1bd46df48df6aacc7c6439d9daf19cac15975180aa5/ruff-0.17.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cfd783590edf2b77317882631900f6a13ea2e8eacefe6ebf2370d2715299160", size = 9990478, upload-time = "2026-10-22T18:37:26.584Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/34cda32cbe13adac5c6b291a307a5570d8ebd37c0c86339f0fe628e55632/ruff-0.17.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122258f6ecde0e0dd1bb60b24263c3edde4338caf723fca711cc729f9bfd414a", size = 10673624, upload-time = "2026-10-22T18:37:29.767Z" }, + { url = "https://files.pythonhosted.org/packages/bf/10/5d762a4dbc851d8bb47863cf3a28a61bf31c804d393b70d891fac899f5d1/ruff-0.17.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f8fba429d6ac4128ab29cfb8fa6240d2fa671d04adf7f84d3b22fc7b6b6bea4", size = 11351797, upload-time = "2026-10-22T18:37:32.535Z" }, + { url = "https://files.pythonhosted.org/packages/3f/04/a0e92a81faf5c81c1c98d4374c8401734ccf2b8cca7d0170cb0fec08c6e2/ruff-0.17.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:583bd67bb9e7061dd09bc46a0a884bae2bbd214dbcd48e509813612619683869", size = 10949414, upload-time = "2026-10-22T18:37:35.501Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d8/59894cf630d2d86882457a2a6a0a1533410c5c805bfbac6f6eb4b5aef69d/ruff-0.17.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c60f93fec7142dba5b8f487f94bf2f6231dae24dc11e4c48648347c8b0720004", size = 10528000, upload-time = "2026-10-22T18:37:37.88Z" }, + { url = "https://files.pythonhosted.org/packages/ab/95/d6e30c51e851d37e10762ad91fa1e76ddaad998589b5667962a3cfbc9edd/ruff-0.17.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c555a877f8f41447e318101cfefea9bf12dfa9f82eedf90c376e57e069f917d0", size = 10571779, upload-time = "2026-10-22T18:37:40.444Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5a/a7e1d74fb464250137d3d4eca645d323536533b672004d04ee0e46e0caf3/ruff-0.17.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba405cbd5ec7bda1f555d5e52be1fd68d02608d4d95d24591bdac92cfacb579f", size = 10051707, upload-time = "2026-10-22T18:37:42.812Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f2/02fca29ca7999380f0350a7212fe755c11ab9c22d61327356cce8decdbc6/ruff-0.17.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c21b4ab24485358f17291a4f8dc5399b834e963b8efedd920fe4fdb02081bb4", size = 9998227, upload-time = "2026-10-22T18:37:45.224Z" }, + { url = "https://files.pythonhosted.org/packages/d1/00/55aa604a093dfdc1526e570fae0bb7d37a2221c7b4728d5f080b2add16ba/ruff-0.17.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5875a16b0de70b43cb81eabea5ae5e8f5d1b2fd1b5e6ed07f80cfe5019d0bd5a", size = 10377692, upload-time = "2026-10-22T18:37:47.704Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5a/a7a640f47bb906ec8fa233ea84319d503e6dee02b59e3901bd312ebd1f9b/ruff-0.17.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d086be452eed686aa51271ecf2cbe5037956517cc6ee39a17b4489ac12f8bbe", size = 10790587, upload-time = "2026-10-22T18:37:50.191Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d4/c3b74e1b4808438ea7f14bb914478025e2d51261f60774a54a0fa222ec46/ruff-0.17.2-py3-none-win32.whl", hash = "sha256:6bce7d1f5165701ae18f5df85c65afe78cddfa6c5fb4b9bdb0014d60388f3edd", size = 9870537, upload-time = "2026-10-22T18:37:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/c7da96c2c96d20704cf9027f7a5121da272a8c05f1d1531ba949e3b3df04/ruff-0.17.2-py3-none-win_amd64.whl", hash = "sha256:1b59d447f197bef9ef48a22ba1a6016137753c6099e9bac9c36eb566e422424c", size = 10282826, upload-time = "2026-10-22T18:37:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/81/60/a3604c79cb02777b6cb84da894104021986f06c4bc41375102cc95049a32/ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962", size = 9827036, upload-time = "2026-10-22T18:37:58.031Z" }, +] + [[package]] name = "setuptools" version = "84.0.0"