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}{tag}>'
+ safe_fallback = html.escape(normalized)
+ return f'<{tag} class="{cls}">{safe_fallback}{tag}>'
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 = [
+ "",
+ "
",
+ "