diff --git a/tests/pycharting/__init__.py b/tests/pycharting/__init__.py new file mode 100644 index 0000000..2fbd625 --- /dev/null +++ b/tests/pycharting/__init__.py @@ -0,0 +1 @@ +"""Test suite mirroring the pycharting package layout.""" diff --git a/tests/pycharting/api/__init__.py b/tests/pycharting/api/__init__.py new file mode 100644 index 0000000..675ed5a --- /dev/null +++ b/tests/pycharting/api/__init__.py @@ -0,0 +1 @@ +"""Tests for pycharting.api.""" diff --git a/tests/pycharting/api/test_interface.py b/tests/pycharting/api/test_interface.py new file mode 100644 index 0000000..3e35a42 --- /dev/null +++ b/tests/pycharting/api/test_interface.py @@ -0,0 +1,443 @@ +"""Tests for Python API interface.""" + +from unittest.mock import patch + +import numpy as np +import pytest + +from pycharting.api.interface import _repr_html_, get_server_status, plot, stop_server +from pycharting.api.routes import _data_managers + + +@pytest.fixture(autouse=True) +def cleanup_globals(): + """Clean up global state between tests. + + Operates on the live ``interface`` module global rather than a name + imported into this module — a plain ``global _active_server`` here would + rebind the test module's own copy and leave the running server (and the + real ``interface._active_server``) untouched, leaking state between tests. + """ + import pycharting.api.interface as iface + + def _reset() -> None: + """Stop any live server and clear module-global session state.""" + if iface._active_server and iface._active_server.is_running: + iface._active_server.stop_server() + iface._active_server = None + _data_managers.clear() + + _reset() + yield + _reset() + + +def test_plot_basic_usage(): + """Test basic plot creation with minimal arguments.""" + # Generate sample data + n = 100 + index = np.arange(n) + close = np.cumsum(np.random.randn(n)) + 100 + open_data = close + np.random.randn(n) * 0.5 + high = np.maximum(open_data, close) + np.abs(np.random.randn(n)) + low = np.minimum(open_data, close) - np.abs(np.random.randn(n)) + + # Create chart without opening browser + result = plot(index, open_data, high, low, close, open_browser=False, block=False) + + assert result["status"] == "success" + assert "url" in result + assert result["data_points"] == n + assert result["server_running"] is True + assert "default" in result["session_id"] + + +def test_plot_with_custom_session(): + """Test plot with custom session ID.""" + n = 50 + index = np.arange(n) + data = np.random.randn(n) + 100 + + result = plot(index, data, data + 1, data - 1, data, session_id="custom_session", open_browser=False, block=False) + + assert result["status"] == "success" + assert result["session_id"] == "custom_session" + assert "custom_session" in _data_managers + + +def test_plot_with_overlays(): + """Test plot with overlay data.""" + n = 100 + index = np.arange(n) + close = np.cumsum(np.random.randn(n)) + 100 + open_data = close + np.random.randn(n) * 0.5 + high = np.maximum(open_data, close) + np.abs(np.random.randn(n)) + low = np.minimum(open_data, close) - np.abs(np.random.randn(n)) + + # Add moving average overlay + ma = np.convolve(close, np.ones(10) / 10, mode="same") + + result = plot(index, open_data, high, low, close, overlays={"MA10": ma}, open_browser=False, block=False) + + assert result["status"] == "success" + # Check that data manager has overlay + dm = _data_managers["default"] + assert "MA10" in dm.overlays + + +def test_plot_reuses_server(): + """Test that multiple plots reuse the same server.""" + n = 50 + data = np.random.randn(n) + 100 + index = np.arange(n) + + # First plot + result1 = plot(index, data, data + 1, data - 1, data, session_id="session1", open_browser=False, block=False) + + first_server_url = result1["server_url"] + + # Second plot should reuse server + result2 = plot(index, data, data + 1, data - 1, data, session_id="session2", open_browser=False, block=False) + + assert result2["server_url"] == first_server_url + assert result1["status"] == "success" + assert result2["status"] == "success" + + +def test_plot_with_invalid_data(): + """Test plot with invalid data returns error.""" + # Try with mismatched array lengths + result = plot( + np.arange(10), + np.random.randn(10), + np.random.randn(10), + np.random.randn(10), + np.random.randn(5), # Wrong length! + open_browser=False, + block=False, + ) + + assert result["status"] == "error" + assert "error" in result + + +@patch("webbrowser.open") +def test_plot_opens_browser(mock_browser): + """Test that plot opens browser when requested.""" + n = 50 + data = np.random.randn(n) + 100 + index = np.arange(n) + + result = plot(index, data, data + 1, data - 1, data, open_browser=True, block=False) + + assert result["status"] == "success" + # Check that browser.open was called + mock_browser.assert_called_once() + call_args = mock_browser.call_args[0][0] + assert "http://" in call_args + + +@patch("webbrowser.open", side_effect=Exception("Browser error")) +def test_plot_handles_browser_error(mock_browser): + """Test that plot handles browser opening errors gracefully.""" + n = 50 + data = np.random.randn(n) + 100 + index = np.arange(n) + + # Should still succeed even if browser fails + result = plot(index, data, data + 1, data - 1, data, open_browser=True, block=False) + + assert result["status"] == "success" + assert result["server_running"] is True + + +def test_stop_server_when_running(): + """Test stopping an active server.""" + # Start a server first + n = 50 + data = np.random.randn(n) + 100 + index = np.arange(n) + + plot(index, data, data + 1, data - 1, data, open_browser=False, block=False) + + # Now stop it + stop_server() + + status = get_server_status() + assert status["running"] is False + + +def test_stop_server_when_not_running(capsys): + """Test stopping when no server is active reports the no-op message.""" + import pycharting.api.interface as iface + + # Force the module global to None so the "no active server" branch is + # exercised deterministically, regardless of test execution order / + # xdist worker state. + original = iface._active_server + iface._active_server = None + try: + stop_server() # Should not raise; should emit the info message + assert "No active server to stop" in capsys.readouterr().out + finally: + iface._active_server = original + + +def test_status_when_no_server(): + """Test status when no server has been started.""" + status = get_server_status() + + assert status["running"] is False + # server_info may exist from previous server, just check it's not running + assert status["active_sessions"] == 0 + + +def test_status_when_server_running(): + """Test status when server is active.""" + n = 50 + data = np.random.randn(n) + 100 + index = np.arange(n) + + # Start server + plot(index, data, data + 1, data - 1, data, open_browser=False, block=False) + + status = get_server_status() + + assert status["running"] is True + assert status["server_info"] is not None + assert status["active_sessions"] >= 1 + assert "host" in status["server_info"] + assert "port" in status["server_info"] + + +def test_plot_with_numpy_arrays(): + """Test plot with NumPy arrays.""" + n = 50 + index = np.arange(n) + close = np.random.randn(n) + 100 + + result = plot(index, close, close + 1, close - 1, close, open_browser=False, block=False) + + assert result["status"] == "success" + + +def test_plot_with_lists(): + """Test plot with Python lists.""" + n = 50 + index = list(range(n)) + close = [100 + i * 0.1 for i in range(n)] + + result = plot(index, close, [c + 1 for c in close], [c - 1 for c in close], close, open_browser=False, block=False) + + assert result["status"] == "success" + + +def test_full_workflow(): + """Test complete workflow: plot -> check status -> stop.""" + # Generate data + n = 100 + index = np.arange(n) + close = np.cumsum(np.random.randn(n)) + 100 + open_data = close + np.random.randn(n) * 0.5 + high = np.maximum(open_data, close) + np.abs(np.random.randn(n)) + low = np.minimum(open_data, close) - np.abs(np.random.randn(n)) + + # 1. Create chart + result = plot(index, open_data, high, low, close, session_id="workflow_test", open_browser=False, block=False) + assert result["status"] == "success" + + # 2. Check status + status = get_server_status() + assert status["running"] is True + assert status["active_sessions"] >= 1 + + # 3. Stop server + stop_server() + + # 4. Verify stopped + status = get_server_status() + assert status["running"] is False + + +def test_multiple_sessions(): + """Test creating multiple chart sessions.""" + n = 50 + data = np.random.randn(n) + 100 + index = np.arange(n) + + # Create multiple sessions + for i in range(3): + result = plot(index, data, data + 1, data - 1, data, session_id=f"session_{i}", open_browser=False, block=False) + assert result["status"] == "success" + + # Check all sessions exist + assert len(_data_managers) >= 3 + for i in range(3): + assert f"session_{i}" in _data_managers + + +def test_repr_html_when_no_server(): + """Verify _repr_html_() reports a stopped state when no server is active.""" + import pycharting.api.interface as iface + + original = iface._active_server + iface._active_server = None + try: + html = _repr_html_() + assert "Stopped" in html + finally: + iface._active_server = original + + +def test_repr_html_when_server_running(): + """Verify _repr_html_() returns embeddable HTML markup while a server is running.""" + n = 50 + data = np.random.randn(n) + 100 + index = np.arange(n) + plot(index, data, data + 1, data - 1, data, open_browser=False, block=False) + html = _repr_html_() + assert html is not None + assert " fetch -> delete.""" + # Initialize + response = client.post("/api/data/init?session_id=workflow") + assert response.status_code == 200 + + # Fetch data + response = client.get("/api/data?session_id=workflow&start_index=0&end_index=100") + assert response.status_code == 200 + assert len(response.json()["index"]) == 100 + + # List sessions + response = client.get("/api/sessions") + assert response.status_code == 200 + assert response.json()["count"] == 1 + + # Delete session + response = client.delete("/api/sessions/workflow") + assert response.status_code == 200 + + # Verify data is inaccessible + response = client.get("/api/data?session_id=workflow") + assert response.status_code == 404 + + +def test_multiple_sessions(client): + """Test working with multiple sessions simultaneously.""" + # Create multiple sessions + for i in range(3): + response = client.post(f"/api/data/init?session_id=session{i}") + assert response.status_code == 200 + + # Fetch from each + for i in range(3): + response = client.get(f"/api/data?session_id=session{i}&start_index=0&end_index=10") + assert response.status_code == 200 + + # Verify all exist + response = client.get("/api/sessions") + assert response.json()["count"] == 3 + + +def test_get_data_internal_error_returns_500(client): + """Verify the data endpoint returns 500 when get_chunk raises an error.""" + from unittest.mock import MagicMock + + from pycharting.api.routes import _data_managers + + mock_dm = MagicMock() + mock_dm.get_chunk.side_effect = RuntimeError("disk failure") + _data_managers["err"] = mock_dm + try: + response = client.get("/api/data?session_id=err") + assert response.status_code == 500 + finally: + _data_managers.pop("err", None) + + +def test_initialize_data_internal_error_returns_500(client): + """Verify the data init endpoint returns 500 when DataManager raises.""" + from unittest.mock import patch + + with patch("pycharting.data.ingestion.DataManager", side_effect=RuntimeError("boom")): + response = client.post("/api/data/init?session_id=err2") + assert response.status_code == 500 + + +class TestDataResponse: + """Tests for the DataResponse pydantic model.""" + + def test_minimal_construction(self): + """DataResponse builds from the required index/range fields.""" + from pycharting.api.routes import DataResponse + + resp = DataResponse(index=[0, 1, 2], start_index=0, end_index=2, total_length=3) + assert resp.index == [0, 1, 2] + assert resp.overlays == {} + assert resp.subplots == {} + assert resp.close is None + + +class TestErrorResponse: + """Tests for the ErrorResponse pydantic model.""" + + def test_construction_with_detail(self): + """ErrorResponse carries an error message and optional detail.""" + from pycharting.api.routes import ErrorResponse + + err = ErrorResponse(error="boom", detail="stack") + assert err.error == "boom" + assert err.detail == "stack" + + def test_detail_defaults_to_none(self): + """The detail field is optional.""" + from pycharting.api.routes import ErrorResponse + + assert ErrorResponse(error="boom").detail is None diff --git a/tests/pycharting/core/__init__.py b/tests/pycharting/core/__init__.py new file mode 100644 index 0000000..b6f9749 --- /dev/null +++ b/tests/pycharting/core/__init__.py @@ -0,0 +1 @@ +"""Tests for pycharting.core.""" diff --git a/tests/test_lifecycle.py b/tests/pycharting/core/test_lifecycle.py similarity index 63% rename from tests/test_lifecycle.py rename to tests/pycharting/core/test_lifecycle.py index b528742..26b1a2b 100644 --- a/tests/test_lifecycle.py +++ b/tests/pycharting/core/test_lifecycle.py @@ -5,7 +5,7 @@ import pytest -from src.pycharting.core.lifecycle import ChartServer +from pycharting.core.lifecycle import ChartServer class TestChartServer: @@ -145,92 +145,90 @@ def test_websocket_endpoint_added(self): """Test that WebSocket endpoint is added to app.""" server = ChartServer() - # Check that the WebSocket route exists - routes = [route.path for route in server.app.routes] + # Check that the WebSocket route exists. Newer FastAPI/Starlette mix + # route objects that lack a ``.path`` attribute (e.g. ``_IncludedRouter``) + # into ``app.routes``, so read paths defensively. + routes = [path for route in server.app.routes if (path := getattr(route, "path", None))] assert "/ws/heartbeat" in routes -class TestThreadSafety: - """Tests for thread safety.""" +def test_multiple_operations(): + """Test multiple start/stop operations.""" + server = ChartServer() - def test_multiple_operations(self): - """Test multiple start/stop operations.""" - server = ChartServer() + for _i in range(3): + server.start_server() + time.sleep(0.3) + assert server.is_running - for _i in range(3): - server.start_server() - time.sleep(0.3) - assert server.is_running + server.stop_server() + time.sleep(0.3) + assert not server.is_running - server.stop_server() - time.sleep(0.3) - assert not server.is_running - def test_server_cleanup(self): - """Test that server properly cleans up resources.""" - server = ChartServer() +def test_server_cleanup(): + """Test that server properly cleans up resources.""" + server = ChartServer() - server.start_server() - time.sleep(0.5) + server.start_server() + time.sleep(0.5) - # Get thread references - server_thread = server._server_thread - monitor_thread = server._monitor_thread + # Get thread references + server_thread = server._server_thread + monitor_thread = server._monitor_thread - server.stop_server() - time.sleep(1) + server.stop_server() + time.sleep(1) - # Threads should be finished - if server_thread: - assert not server_thread.is_alive() - if monitor_thread: - assert not monitor_thread.is_alive() + # Threads should be finished + if server_thread: + assert not server_thread.is_alive() + if monitor_thread: + assert not monitor_thread.is_alive() -class TestServerIntegration: - """Integration tests for server functionality.""" +def test_server_responds_after_start(): + """Test that server responds to requests after starting.""" + import httpx - def test_server_responds_after_start(self): - """Test that server responds to requests after starting.""" - import httpx + server = ChartServer() - server = ChartServer() + try: + info = server.start_server() + time.sleep(1) # Give server time to start - try: - info = server.start_server() - time.sleep(1) # Give server time to start + # Try to access health endpoint + response = httpx.get(f"{info['url']}/health", timeout=5) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" - # Try to access health endpoint - response = httpx.get(f"{info['url']}/health", timeout=5) - assert response.status_code == 200 - data = response.json() - assert data["status"] == "healthy" + except Exception as e: + pytest.skip(f"Server integration test skipped: {e}") - except Exception as e: - pytest.skip(f"Server integration test skipped: {e}") + finally: + server.stop_server() - finally: - server.stop_server() - def test_server_accessible_in_background(self): - """Test that main thread is not blocked.""" - import httpx +def test_server_accessible_in_background(): + """Test that main thread is not blocked.""" + import httpx - server = ChartServer() + server = ChartServer() - try: - server.start_server() - time.sleep(1) + try: + server.start_server() + time.sleep(1) - # Main thread should not be blocked - # We should be able to make multiple requests - for _ in range(3): - response = httpx.get(f"http://{server.host}:{server.port}/health", timeout=5) - assert response.status_code == 200 - time.sleep(0.1) + # Main thread should not be blocked + # We should be able to make multiple requests + for _ in range(3): + response = httpx.get(f"http://{server.host}:{server.port}/health", timeout=5) + assert response.status_code == 200 + time.sleep(0.1) - except Exception as e: - pytest.skip(f"Integration test skipped: {e}") + except Exception as e: + pytest.skip(f"Integration test skipped: {e}") - finally: - server.stop_server() + finally: + server.stop_server() diff --git a/tests/pycharting/core/test_server.py b/tests/pycharting/core/test_server.py new file mode 100644 index 0000000..5743e47 --- /dev/null +++ b/tests/pycharting/core/test_server.py @@ -0,0 +1,258 @@ +"""Tests for FastAPI server.""" + +import pytest +from fastapi.testclient import TestClient + +from pycharting.core.server import NoCacheStaticFiles, create_app, find_free_port, run_server + + +@pytest.fixture +def client(): + """Create a test client for the FastAPI app.""" + app = create_app() + return TestClient(app) + + +def test_finds_free_port(): + """Test that a free port is found.""" + port = find_free_port(8000, 8100) + assert 8000 <= port < 8100 + + +def test_finds_different_ports(): + """Test that different calls can find different ports.""" + port1 = find_free_port(8000, 8100) + port2 = find_free_port(8100, 8200) + assert port1 != port2 or port1 < 8100 + + +def test_scans_from_start_with_default_end(): + """When only start_port is given, the end of the range defaults to start_port + 1000.""" + port = find_free_port(8000) + assert 8000 <= port < 9000 + + +def test_returns_ephemeral_port_with_no_arguments(): + """With no arguments, an OS-assigned ephemeral port is returned.""" + port = find_free_port() + assert port > 0 + + +def test_raises_on_no_free_port(): + """Test that RuntimeError is raised when no port is free.""" + import socket + + # Bind on the same interface find_free_port scans ("127.0.0.1"). + # On Windows, 127.0.0.1:port does not conflict with 0.0.0.0:port, so the + # occupied port must be claimed on the same address to be seen as taken. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + occupied_port = s.getsockname()[1] + with pytest.raises(RuntimeError, match="No free port found"): + find_free_port(occupied_port, occupied_port + 1) + + +def test_app_created(client): + """Test that app is created successfully.""" + assert client is not None + + +def test_app_has_cors(client): + """Test that CORS middleware is configured.""" + response = client.options("/health") + # CORS headers should be present + assert response.status_code in [200, 405] # OPTIONS might not be explicitly handled + + +def test_root_returns_html(client): + """Test that root endpoint returns HTML.""" + response = client.get("/") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "PyCharting" in response.text + + +def test_root_has_welcome_message(client): + """Test that root page has welcome message.""" + response = client.get("/") + assert "Welcome to PyCharting" in response.text + assert "server is running successfully" in response.text + + +def test_root_has_api_docs_link(client): + """Test that root page links to API docs.""" + response = client.get("/") + assert "/api/docs" in response.text + + +def test_health_check_returns_200(client): + """Test that health check returns 200.""" + response = client.get("/health") + assert response.status_code == 200 + + +def test_health_check_returns_json(client): + """Test that health check returns JSON.""" + response = client.get("/health") + assert response.headers["content-type"] == "application/json" + + +def test_health_check_has_status(client): + """Test that health check includes status.""" + response = client.get("/health") + data = response.json() + assert "status" in data + assert data["status"] == "healthy" + + +def test_health_check_has_service_name(client): + """Test that health check includes service name.""" + response = client.get("/health") + data = response.json() + assert "service" in data + assert data["service"] == "pycharting" + + +def test_static_route_exists(client): + """Test that static route is mounted.""" + # This will return 404 if no file exists, but route should be there + response = client.get("/static/nonexistent.js") + # Should return 404, not 405 or other routing error + assert response.status_code == 404 + + +def test_openapi_json_available(client): + """Test that OpenAPI JSON is available.""" + response = client.get("/openapi.json") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + + +def test_openapi_has_title(client): + """Test that OpenAPI spec has correct title.""" + response = client.get("/openapi.json") + data = response.json() + assert data["info"]["title"] == "PyCharting" + + +def test_openapi_has_version(client): + """Test that OpenAPI spec has version.""" + response = client.get("/openapi.json") + data = response.json() + assert data["info"]["version"] == "0.1.0" + + +def test_docs_endpoint_available(client): + """Test that docs UI is available.""" + response = client.get("/api/docs") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + +def test_redoc_endpoint_available(client): + """Test that ReDoc UI is available.""" + response = client.get("/api/redoc") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + +def test_404_on_nonexistent_route(client): + """Test that 404 is returned for nonexistent routes.""" + response = client.get("/nonexistent") + assert response.status_code == 404 + + +def test_404_returns_json(client): + """Test that 404 error returns JSON.""" + response = client.get("/api/nonexistent") + # Might return HTML for non-API routes, but should handle gracefully + assert response.status_code == 404 + + +def test_cors_headers_on_health_check(client): + """Test that CORS headers are present.""" + response = client.get("/health", headers={"Origin": "http://localhost:3000"}) + # CORS middleware should add headers + assert response.status_code == 200 + # FastAPI's TestClient might not fully simulate CORS, but endpoint should work + + +def test_endpoint_accessible(client): + """Test that endpoints are accessible (CORS doesn't block).""" + response = client.get("/health") + assert response.status_code == 200 + + +def test_app_has_correct_metadata(client): + """Test that app metadata is correct.""" + response = client.get("/openapi.json") + data = response.json() + + assert data["info"]["title"] == "PyCharting" + assert data["info"]["description"] == "Interactive charting and data visualization API" + assert data["info"]["version"] == "0.1.0" + + +class TestNoCacheStaticFiles: + """Tests for NoCacheStaticFiles middleware.""" + + def test_adds_no_cache_headers(self, tmp_path): + """Verify NoCacheStaticFiles serves files with no-cache headers.""" + from fastapi import FastAPI + + test_file = tmp_path / "test.txt" + test_file.write_text("hello") + + app = FastAPI() + app.mount("/static", NoCacheStaticFiles(directory=str(tmp_path)), name="static") + client = TestClient(app) + + response = client.get("/static/test.txt") + assert response.status_code == 200 + assert "no-cache" in response.headers.get("cache-control", "") + assert response.headers.get("pragma") == "no-cache" + assert response.headers.get("expires") == "0" + + +def test_run_server_auto_port(): + """Verify run_server selects a positive port automatically by default.""" + from unittest.mock import MagicMock, patch + + with patch("pycharting.core.server.uvicorn") as mock_uvicorn: + mock_uvicorn.run = MagicMock() + run_server() + mock_uvicorn.run.assert_called_once() + assert mock_uvicorn.run.call_args.kwargs["port"] > 0 + + +def test_run_server_specific_port(): + """Verify run_server uses the given port when auto_port is disabled.""" + import socket + from unittest.mock import MagicMock, patch + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + free_port = s.getsockname()[1] + with patch("pycharting.core.server.uvicorn") as mock_uvicorn: + mock_uvicorn.run = MagicMock() + run_server(port=free_port, auto_port=False) + assert mock_uvicorn.run.call_args.kwargs["port"] == free_port + + +def test_run_server_auto_port_fallback(): + """Verify run_server falls back to a free port when the requested port is occupied.""" + import socket + from unittest.mock import MagicMock, patch + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + occupied = s.getsockname()[1] + fallback = occupied + 1 + with ( + patch("pycharting.core.server.uvicorn") as mock_uvicorn, + patch("pycharting.core.server.find_free_port", return_value=fallback) as mock_ffp, + ): + mock_uvicorn.run = MagicMock() + run_server(host="127.0.0.1", port=occupied, auto_port=True) + mock_ffp.assert_called_once_with(occupied + 1) + assert mock_uvicorn.run.call_args.kwargs["port"] == fallback diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py deleted file mode 100644 index b220e7d..0000000 --- a/tests/test_api_routes.py +++ /dev/null @@ -1,260 +0,0 @@ -"""Tests for API routes.""" - -import pytest -from fastapi.testclient import TestClient - -from pycharting.core.server import create_app - - -@pytest.fixture -def client(): - """Create test client.""" - # Clear session state before each test - from pycharting.api.routes import _data_managers - - _data_managers.clear() - - app = create_app() - return TestClient(app) - - -class TestAPIStatus: - """Tests for API status endpoint.""" - - def test_api_status(self, client): - """Test API status endpoint.""" - response = client.get("/api/status") - assert response.status_code == 200 - - data = response.json() - assert data["status"] == "healthy" - assert "active_sessions" in data - assert "endpoints" in data - - -class TestDataInitialization: - """Tests for data initialization endpoint.""" - - def test_init_default_session(self, client): - """Test initializing default session.""" - response = client.post("/api/data/init") - assert response.status_code == 200 - - data = response.json() - assert data["session_id"] == "default" - assert data["status"] == "initialized" - assert data["data_points"] == 1000 - - def test_init_custom_session(self, client): - """Test initializing custom session.""" - response = client.post("/api/data/init?session_id=test") - assert response.status_code == 200 - - data = response.json() - assert data["session_id"] == "test" - assert data["status"] == "initialized" - - -class TestDataFetching: - """Tests for data fetching endpoint.""" - - def test_get_data_without_session(self, client): - """Test getting data without initializing session.""" - response = client.get("/api/data") - assert response.status_code == 404 - response_json = response.json() - # Check for error message in either 'detail' or 'error' key - error_msg = response_json.get("detail") or response_json.get("error", "") - assert "not found" in error_msg.lower() - - def test_get_data_with_session(self, client): - """Test getting data after initialization.""" - # Initialize session - client.post("/api/data/init") - - # Fetch data - response = client.get("/api/data?start_index=0&end_index=100") - assert response.status_code == 200 - - data = response.json() - assert "index" in data - assert "open" in data - assert "high" in data - assert "low" in data - assert "close" in data - assert len(data["index"]) == 100 - - def test_get_data_full_range(self, client): - """Test getting all data.""" - client.post("/api/data/init") - - response = client.get("/api/data") - assert response.status_code == 200 - - data = response.json() - assert data["total_length"] == 1000 - assert len(data["index"]) == 1000 - - def test_get_data_partial_range(self, client): - """Test getting partial data range.""" - client.post("/api/data/init") - - response = client.get("/api/data?start_index=500&end_index=600") - assert response.status_code == 200 - - data = response.json() - assert data["start_index"] == 500 - assert data["end_index"] == 600 - assert len(data["index"]) == 100 - - def test_get_data_with_custom_session(self, client): - """Test getting data from custom session.""" - client.post("/api/data/init?session_id=custom") - - response = client.get("/api/data?session_id=custom&start_index=0&end_index=50") - assert response.status_code == 200 - - data = response.json() - assert len(data["index"]) == 50 - - -class TestSessionManagement: - """Tests for session management endpoints.""" - - def test_list_sessions_empty(self, client): - """Test listing sessions when none exist.""" - response = client.get("/api/sessions") - assert response.status_code == 200 - - data = response.json() - assert data["count"] == 0 - assert len(data["sessions"]) == 0 - - def test_list_sessions_with_data(self, client): - """Test listing sessions after initialization.""" - client.post("/api/data/init") - client.post("/api/data/init?session_id=test") - - response = client.get("/api/sessions") - assert response.status_code == 200 - - data = response.json() - assert data["count"] == 2 - assert len(data["sessions"]) == 2 - - def test_delete_session(self, client): - """Test deleting a session.""" - client.post("/api/data/init?session_id=to_delete") - - # Verify session exists - response = client.get("/api/sessions") - assert response.json()["count"] == 1 - - # Delete session - response = client.delete("/api/sessions/to_delete") - assert response.status_code == 200 - assert response.json()["status"] == "deleted" - - # Verify session is gone - response = client.get("/api/sessions") - assert response.json()["count"] == 0 - - def test_delete_nonexistent_session(self, client): - """Test deleting non-existent session.""" - response = client.delete("/api/sessions/nonexistent") - assert response.status_code == 404 - - -class TestDataValidation: - """Tests for data validation.""" - - def test_negative_start_index(self, client): - """Test that negative start index is rejected.""" - client.post("/api/data/init") - - response = client.get("/api/data?start_index=-1") - assert response.status_code == 422 # Validation error - - def test_overlays_and_subplots(self, client): - """Test that response includes overlay/subplot fields.""" - client.post("/api/data/init") - - response = client.get("/api/data?start_index=0&end_index=10") - assert response.status_code == 200 - - data = response.json() - assert "overlays" in data - assert "subplots" in data - assert isinstance(data["overlays"], dict) - assert isinstance(data["subplots"], dict) - - -class TestAPIIntegration: - """Integration tests for API.""" - - def test_full_workflow(self, client): - """Test complete workflow: init -> fetch -> delete.""" - # Initialize - response = client.post("/api/data/init?session_id=workflow") - assert response.status_code == 200 - - # Fetch data - response = client.get("/api/data?session_id=workflow&start_index=0&end_index=100") - assert response.status_code == 200 - assert len(response.json()["index"]) == 100 - - # List sessions - response = client.get("/api/sessions") - assert response.status_code == 200 - assert response.json()["count"] == 1 - - # Delete session - response = client.delete("/api/sessions/workflow") - assert response.status_code == 200 - - # Verify data is inaccessible - response = client.get("/api/data?session_id=workflow") - assert response.status_code == 404 - - def test_multiple_sessions(self, client): - """Test working with multiple sessions simultaneously.""" - # Create multiple sessions - for i in range(3): - response = client.post(f"/api/data/init?session_id=session{i}") - assert response.status_code == 200 - - # Fetch from each - for i in range(3): - response = client.get(f"/api/data?session_id=session{i}&start_index=0&end_index=10") - assert response.status_code == 200 - - # Verify all exist - response = client.get("/api/sessions") - assert response.json()["count"] == 3 - - -class TestErrorPaths: - """Tests for internal error handling in routes.""" - - def test_get_data_internal_error_returns_500(self, client): - """Verify the data endpoint returns 500 when get_chunk raises an error.""" - from unittest.mock import MagicMock - - from pycharting.api.routes import _data_managers - - mock_dm = MagicMock() - mock_dm.get_chunk.side_effect = RuntimeError("disk failure") - _data_managers["err"] = mock_dm - try: - response = client.get("/api/data?session_id=err") - assert response.status_code == 500 - finally: - _data_managers.pop("err", None) - - def test_initialize_data_internal_error_returns_500(self, client): - """Verify the data init endpoint returns 500 when DataManager raises.""" - from unittest.mock import patch - - with patch("pycharting.data.ingestion.DataManager", side_effect=RuntimeError("boom")): - response = client.post("/api/data/init?session_id=err2") - assert response.status_code == 500 diff --git a/tests/test_data_ingestion.py b/tests/test_data_ingestion.py deleted file mode 100644 index 9b40066..0000000 --- a/tests/test_data_ingestion.py +++ /dev/null @@ -1,513 +0,0 @@ -"""Tests for data ingestion and validation module.""" - -import numpy as np -import pandas as pd -import pytest - -from pycharting.data.ingestion import DataManager, DataValidationError, validate_input - - -class TestValidateInput: - """Tests for the validate_input function.""" - - def test_valid_pandas_input(self): - """Test validation with valid Pandas Series input.""" - index = pd.date_range("2024-01-01", periods=5) - open_data = pd.Series([100, 102, 101, 103, 102]) - high = pd.Series([105, 106, 105, 107, 106]) - low = pd.Series([99, 100, 99, 101, 100]) - close = pd.Series([104, 103, 104, 105, 104]) - - result = validate_input(index, open_data, high, low, close) - - assert isinstance(result["index"], np.ndarray) - assert isinstance(result["open"], np.ndarray) - assert len(result["index"]) == 5 - assert np.array_equal(result["open"], [100, 102, 101, 103, 102]) - - def test_valid_numpy_input(self): - """Test validation with valid NumPy array input.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - - result = validate_input(index, open_data, high, low, close) - - assert isinstance(result["index"], np.ndarray) - assert len(result["index"]) == 5 - assert np.array_equal(result["close"], [104, 103, 104, 105, 104]) - - def test_with_overlays(self): - """Test validation with overlay data.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - overlays = { - "SMA20": np.array([101, 102, 102, 103, 103]), - "EMA10": np.array([100, 101, 101, 102, 102]), - } - - result = validate_input(index, open_data, high, low, close, overlays=overlays) - - assert len(result["overlays"]) == 2 - assert "SMA20" in result["overlays"] - assert "EMA10" in result["overlays"] - assert np.array_equal(result["overlays"]["SMA20"], [101, 102, 102, 103, 103]) - - def test_with_subplots(self): - """Test validation with subplot data.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - subplots = { - "Volume": np.array([1000, 1200, 1100, 1300, 1150]), - "RSI": np.array([55, 58, 52, 60, 57]), - } - - result = validate_input(index, open_data, high, low, close, subplots=subplots) - - assert len(result["subplots"]) == 2 - assert "Volume" in result["subplots"] - assert "RSI" in result["subplots"] - - def test_invalid_index_type(self): - """Test that invalid index type raises error.""" - index = [1, 2, 3, 4, 5] # List instead of Index or ndarray - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - - with pytest.raises(DataValidationError, match="Index must be"): - validate_input(index, open_data, high, low, close) - - def test_length_mismatch(self): - """Test that mismatched lengths raise error.""" - index = np.arange(5) - open_data = np.array([100, 102, 101]) # Length 3 - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - - with pytest.raises(DataValidationError, match="does not match index length"): - validate_input(index, open_data, high, low, close) - - def test_ohlc_constraint_high_violation(self): - """Test that High < max(Open, Close) raises error.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([99, 106, 105, 107, 106]) # First high < open - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - - with pytest.raises(DataValidationError, match="High must be >= max"): - validate_input(index, open_data, high, low, close) - - def test_ohlc_constraint_low_violation(self): - """Test that Low > min(Open, Close) raises error.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([101, 100, 99, 101, 100]) # First low > open - close = np.array([104, 103, 104, 105, 104]) - - with pytest.raises(DataValidationError, match="Low must be <= min"): - validate_input(index, open_data, high, low, close) - - def test_overlay_length_mismatch(self): - """Test that mismatched overlay length raises error.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - overlays = {"SMA20": np.array([101, 102, 102])} # Length 3 - - with pytest.raises(DataValidationError, match=r"Overlay.*does not match"): - validate_input(index, open_data, high, low, close, overlays=overlays) - - -class TestDataManager: - """Tests for the DataManager class.""" - - def test_init_with_numpy_arrays(self): - """Test initialization with NumPy arrays.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - - dm = DataManager(index, open_data, high, low, close) - - assert len(dm) == 5 - assert dm.length == 5 - assert isinstance(dm.open, np.ndarray) - assert np.array_equal(dm.open, open_data) - - def test_init_with_pandas_series(self): - """Test initialization with Pandas Series.""" - index = pd.date_range("2024-01-01", periods=5) - open_data = pd.Series([100, 102, 101, 103, 102]) - high = pd.Series([105, 106, 105, 107, 106]) - low = pd.Series([99, 100, 99, 101, 100]) - close = pd.Series([104, 103, 104, 105, 104]) - - dm = DataManager(index, open_data, high, low, close) - - assert len(dm) == 5 - assert isinstance(dm.close, np.ndarray) - assert dm.close[0] == 104 - - def test_properties(self): - """Test all property accessors.""" - index = np.arange(3) - open_data = np.array([100, 102, 101]) - high = np.array([105, 106, 105]) - low = np.array([99, 100, 99]) - close = np.array([104, 103, 104]) - - trades = np.array([1, 0, -1]) - subplots = {"Volume": {"data": np.array([10, 20, 30]), "type": "bar"}} - - dm = DataManager(index, open_data, high, low, close, subplots=subplots, trades=trades) - - assert np.array_equal(dm.index, index) - assert np.array_equal(dm.open, open_data) - assert np.array_equal(dm.high, high) - assert np.array_equal(dm.low, low) - assert np.array_equal(dm.close, close) - assert isinstance(dm.overlays, dict) - assert isinstance(dm.subplots, dict) - assert np.array_equal(dm.trades, trades) - assert dm.subplot_meta["Volume"][0]["type"] == "bar" - - def test_with_overlays_and_subplots(self): - """Test initialization with overlays and subplots.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - overlays = {"SMA20": np.array([101, 102, 102, 103, 103])} - subplots = {"Volume": np.array([1000, 1200, 1100, 1300, 1150])} - - dm = DataManager(index, open_data, high, low, close, overlays, subplots) - - assert len(dm.overlays) == 1 - assert "SMA20" in dm.overlays - assert len(dm.subplots) == 1 - assert "Volume" in dm.subplots - assert np.array_equal(dm.overlays["SMA20"], [101, 102, 102, 103, 103]) - - def test_invalid_data_raises_error(self): - """Test that invalid OHLC data raises DataValidationError.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([99, 106, 105, 107, 106]) # First high < open - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - - with pytest.raises(DataValidationError): - DataManager(index, open_data, high, low, close) - - def test_repr(self): - """Test string representation.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - - dm = DataManager(index, open_data, high, low, close) - repr_str = repr(dm) - - assert "DataManager" in repr_str - assert "5 points" in repr_str - - def test_repr_with_overlays(self): - """Test string representation with overlays.""" - index = np.arange(3) - open_data = np.array([100, 102, 101]) - high = np.array([105, 106, 105]) - low = np.array([99, 100, 99]) - close = np.array([104, 103, 104]) - overlays = {"SMA20": np.array([101, 102, 102])} - - dm = DataManager(index, open_data, high, low, close, overlays=overlays) - repr_str = repr(dm) - - assert "1 overlays" in repr_str - - def test_no_data_duplication(self): - """Test that data is not duplicated unnecessarily.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - - dm = DataManager(index, open_data, high, low, close) - - # Verify arrays are stored (conversion happened but data is referenced) - assert dm.open.dtype == open_data.dtype - assert len(dm.open) == len(open_data) - - def test_timestamp_conversion_to_milliseconds(self): - """Test that DatetimeIndex is converted to Unix timestamps in milliseconds.""" - # Create a DatetimeIndex with known timestamps - index = pd.date_range("2024-01-01", periods=5, freq="h") - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - - dm = DataManager(index, open_data, high, low, close) - - # Get chunk should return timestamps in milliseconds - chunk = dm.get_chunk(0, 5) - - # Verify that index is a list of integers (Unix timestamps in milliseconds) - assert isinstance(chunk["index"], list) - assert all(isinstance(x, int) for x in chunk["index"]) - - # Verify timestamps are in the correct range (milliseconds since epoch) - # For 2024-01-01, timestamps should be around 1704067200000 (ms) - expected_first_ts = int(pd.Timestamp("2024-01-01").timestamp() * 1000) - assert chunk["index"][0] == expected_first_ts - - # Verify timestamps are 1 hour apart (3600000 ms) - assert chunk["index"][1] - chunk["index"][0] == 3600000 - - def test_numeric_index_unchanged(self): - """Test that numeric indices are not converted to timestamps.""" - # Use plain numeric index - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - - dm = DataManager(index, open_data, high, low, close) - - # Get chunk should return plain numeric indices - chunk = dm.get_chunk(0, 5) - - # Verify that index is unchanged - assert chunk["index"] == [0, 1, 2, 3, 4] - - def test_unix_timestamp_index_unchanged(self): - """Test that raw Unix timestamps (already in milliseconds) pass through unchanged.""" - # Use Unix timestamps in milliseconds (like JavaScript Date.now()) - base_ts = 1704067200000 # 2024-01-01 in milliseconds - index = np.array([base_ts + i * 3600000 for i in range(5)]) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - - dm = DataManager(index, open_data, high, low, close) - - # Get chunk should return timestamps unchanged - chunk = dm.get_chunk(0, 5) - - # Verify timestamps are preserved - assert chunk["index"] == index.tolist() - assert all(isinstance(x, int) for x in chunk["index"]) - - def test_timezone_aware_index(self): - """Test that timezone-aware indices are correctly converted to milliseconds.""" - # Create a timezone-aware index (UTC) - index = pd.date_range("2024-01-01", periods=5, freq="h", tz="UTC") - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - - dm = DataManager(index, open_data, high, low, close) - - # Get chunk should return valid integer timestamps, NOT Timestamps objects - chunk = dm.get_chunk(0, 5) - - # Verify conversion - assert isinstance(chunk["index"], list) - assert all(isinstance(x, int) for x in chunk["index"]) - - # Expected timestamp (1704067200000 for 2024-01-01 UTC) - expected_ts = 1704067200000 - assert chunk["index"][0] == expected_ts - - -class TestValidateInputEdgeCases: - """Tests for edge cases in validate_input.""" - - def test_to_array_none_returns_none(self): - """Verify omitted data series default to None in the result.""" - index = np.arange(5) - close = np.array([100, 102, 103, 104, 105]) - result = validate_input(index, close=close) - assert result["open"] is None - assert result["high"] is None - assert result["low"] is None - - def test_to_array_list_input(self): - """Verify plain Python lists are converted to numpy arrays.""" - index = np.arange(5) - result = validate_input( - index, - [100, 102, 101, 103, 102], - [105, 106, 105, 107, 106], - [99, 100, 99, 101, 100], - [104, 103, 104, 105, 104], - ) - assert isinstance(result["open"], np.ndarray) - assert np.array_equal(result["open"], [100, 102, 101, 103, 102]) - - def test_to_array_invalid_type_raises(self): - """Verify passing a non-array-like type as a data series raises an error.""" - index = np.arange(5) - with pytest.raises(DataValidationError): - validate_input(index, "invalid", np.zeros(5), np.zeros(5), np.zeros(5)) - - def test_no_series_raises(self): - """Verify calling validate_input with no data series raises an error.""" - index = np.arange(5) - with pytest.raises(DataValidationError, match="At least one data series"): - validate_input(index) - - def test_single_series_mode(self): - """Verify passing only close yields a single-series result with other fields None.""" - index = np.arange(5) - close = np.array([100, 102, 103, 104, 105]) - result = validate_input(index, close=close) - assert result["open"] is None - assert result["high"] is None - assert result["low"] is None - assert np.array_equal(result["close"], close) - - def test_open_fallback_when_none(self): - """Verify open falls back to close when open is None.""" - index = np.arange(5) - high = np.array([106, 108, 107, 109, 108]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - result = validate_input(index, None, high, low, close) - assert np.array_equal(result["open"], close) - - def test_close_fallback_when_none(self): - """Verify close falls back to open when close is None.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - result = validate_input(index, open_data, high, low, None) - assert np.array_equal(result["close"], open_data) - - def test_open_close_fallback_when_both_none(self): - """When neither open nor close is given, both fall back to the first provided series.""" - index = np.arange(5) - high = np.array([106, 108, 107, 109, 108]) - low = np.array([99, 100, 99, 101, 100]) - result = validate_input(index, None, high, low, None) - # high is the first provided series, so open and close both default to it. - assert np.array_equal(result["open"], high) - assert np.array_equal(result["close"], high) - - def test_high_auto_computed(self): - """Verify high is auto-computed as the element-wise maximum of open and close.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - close = np.array([104, 101, 104, 102, 105]) - result = validate_input(index, open_data, None, None, close) - assert np.array_equal(result["high"], np.maximum(open_data, close)) - - def test_low_auto_computed(self): - """Verify low is auto-computed as the element-wise minimum of open and close.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - close = np.array([104, 101, 104, 102, 105]) - result = validate_input(index, open_data, None, None, close) - assert np.array_equal(result["low"], np.minimum(open_data, close)) - - def test_trades_valid(self): - """Verify a valid trades array is accepted and cast to int8.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - trades = np.array([1, 0, -1, 0, 1]) - result = validate_input(index, open_data, high, low, close, trades=trades) - assert result["trades"] is not None - assert result["trades"].dtype == np.int8 - - def test_trades_invalid_values_raise(self): - """Verify a trades array with values outside -1, 0, 1 raises an error.""" - index = np.arange(5) - close = np.array([100, 102, 103, 104, 105]) - with pytest.raises(DataValidationError, match="Trades array must contain only"): - validate_input(index, close=close, trades=np.array([1, 2, -1, 0, 1])) - - def test_subplot_multi_series_format(self): - """Verify a list of subplot series is split into indexed keys with metadata.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - subplots = { - "MACD": [ - {"data": np.array([1.0, 1.5, 2.0, 1.5, 1.0]), "type": "line", "label": "MACD", "color": "#f00"}, - {"data": np.array([0.5, 0.8, 1.0, 0.7, 0.5]), "type": "bar"}, - ] - } - result = validate_input(index, open_data, high, low, close, subplots=subplots) - assert "MACD__0" in result["subplots"] - assert "MACD__1" in result["subplots"] - assert len(result["subplot_meta"]["MACD"]) == 2 - - def test_subplot_dict_format(self): - """Verify a single-dict subplot definition is parsed with its type metadata.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - subplots = {"Volume": {"data": np.array([1000, 1200, 800, 1500, 1100]), "type": "bar", "color": "#0f0"}} - result = validate_input(index, open_data, high, low, close, subplots=subplots) - assert "Volume" in result["subplots"] - assert result["subplot_meta"]["Volume"][0]["type"] == "bar" - - -class TestDataManagerEdgeCases: - """Tests for DataManager repr and chunk edge cases.""" - - def test_repr_with_subplots(self): - """Verify the DataManager repr reports the number of subplots.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - subplots = {"RSI": np.array([55, 58, 52, 60, 57])} - dm = DataManager(index, open_data, high, low, close, subplots=subplots) - assert "1 subplots" in repr(dm) - - def test_get_chunk_includes_subplot_meta(self): - """Verify get_chunk includes subplot_meta with subplot keys in the result.""" - index = np.arange(5) - open_data = np.array([100, 102, 101, 103, 102]) - high = np.array([105, 106, 105, 107, 106]) - low = np.array([99, 100, 99, 101, 100]) - close = np.array([104, 103, 104, 105, 104]) - subplots = {"Volume": np.array([1000, 1200, 1100, 1300, 1150])} - dm = DataManager(index, open_data, high, low, close, subplots=subplots) - chunk = dm.get_chunk(0, 5) - assert "subplot_meta" in chunk - assert "Volume" in chunk["subplot_meta"] diff --git a/tests/test_data_slicing.py b/tests/test_data_slicing.py deleted file mode 100644 index c5d3f28..0000000 --- a/tests/test_data_slicing.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Tests for data slicing functionality.""" - -import time - -import numpy as np - -from pycharting.data.ingestion import DataManager - - -class TestGetChunk: - """Tests for the get_chunk method.""" - - def test_basic_chunk(self): - """Test basic chunk retrieval.""" - index = np.arange(10) - open_data = np.array([100, 102, 101, 103, 102, 104, 103, 105, 104, 106]) - high = np.array([105, 106, 105, 107, 106, 108, 107, 109, 108, 110]) - low = np.array([99, 100, 99, 101, 100, 102, 101, 103, 102, 104]) - close = np.array([104, 103, 104, 105, 104, 106, 105, 107, 106, 108]) - - dm = DataManager(index, open_data, high, low, close) - chunk = dm.get_chunk(0, 5) - - assert len(chunk["index"]) == 5 - assert chunk["index"] == [0, 1, 2, 3, 4] - assert chunk["open"] == [100, 102, 101, 103, 102] - assert chunk["close"] == [104, 103, 104, 105, 104] - - def test_middle_chunk(self): - """Test retrieving a chunk from the middle of data.""" - index = np.arange(10) - open_data = np.arange(100, 110) - high = np.arange(105, 115) - low = np.arange(95, 105) - close = np.arange(102, 112) - - dm = DataManager(index, open_data, high, low, close) - chunk = dm.get_chunk(3, 7) - - assert len(chunk["index"]) == 4 - assert chunk["index"] == [3, 4, 5, 6] - assert chunk["open"] == [103, 104, 105, 106] - - def test_chunk_with_none_start(self): - """Test chunk with None start index (from beginning).""" - index = np.arange(10) - open_data = np.arange(100, 110) - high = np.arange(105, 115) - low = np.arange(95, 105) - close = np.arange(102, 112) - - dm = DataManager(index, open_data, high, low, close) - chunk = dm.get_chunk(None, 5) - - assert len(chunk["index"]) == 5 - assert chunk["index"] == [0, 1, 2, 3, 4] - - def test_chunk_with_none_end(self): - """Test chunk with None end index (to end).""" - index = np.arange(10) - open_data = np.arange(100, 110) - high = np.arange(105, 115) - low = np.arange(95, 105) - close = np.arange(102, 112) - - dm = DataManager(index, open_data, high, low, close) - chunk = dm.get_chunk(7, None) - - assert len(chunk["index"]) == 3 - assert chunk["index"] == [7, 8, 9] - assert chunk["open"] == [107, 108, 109] - - def test_chunk_with_both_none(self): - """Test chunk with both indices None (entire dataset).""" - index = np.arange(5) - open_data = np.arange(100, 105) - high = np.arange(105, 110) - low = np.arange(95, 100) - close = np.arange(102, 107) - - dm = DataManager(index, open_data, high, low, close) - chunk = dm.get_chunk(None, None) - - assert len(chunk["index"]) == 5 - assert chunk["index"] == [0, 1, 2, 3, 4] - - def test_empty_chunk(self): - """Test empty chunk when start equals end.""" - index = np.arange(10) - open_data = np.arange(100, 110) - high = np.arange(105, 115) - low = np.arange(95, 105) - close = np.arange(102, 112) - - dm = DataManager(index, open_data, high, low, close) - chunk = dm.get_chunk(5, 5) - - assert len(chunk["index"]) == 0 - assert chunk["open"] == [] - - def test_out_of_bounds_positive(self): - """Test chunk with indices beyond data length.""" - index = np.arange(10) - open_data = np.arange(100, 110) - high = np.arange(105, 115) - low = np.arange(95, 105) - close = np.arange(102, 112) - - dm = DataManager(index, open_data, high, low, close) - chunk = dm.get_chunk(8, 20) # End beyond length - - assert len(chunk["index"]) == 2 # Clamped to available data - assert chunk["index"] == [8, 9] - - def test_out_of_bounds_negative(self): - """Test chunk with negative start index.""" - index = np.arange(10) - open_data = np.arange(100, 110) - high = np.arange(105, 115) - low = np.arange(95, 105) - close = np.arange(102, 112) - - dm = DataManager(index, open_data, high, low, close) - chunk = dm.get_chunk(-5, 5) # Negative start - - # Negative indices should be clamped to 0 - assert len(chunk["index"]) == 5 - assert chunk["index"] == [0, 1, 2, 3, 4] - - def test_inverted_indices(self): - """Test chunk with start > end (should return empty).""" - index = np.arange(10) - open_data = np.arange(100, 110) - high = np.arange(105, 115) - low = np.arange(95, 105) - close = np.arange(102, 112) - - dm = DataManager(index, open_data, high, low, close) - chunk = dm.get_chunk(7, 3) # Start > end - - # Should clamp end_index to be at least start_index - assert len(chunk["index"]) == 0 - - def test_chunk_with_overlays(self): - """Test chunk includes overlay data.""" - index = np.arange(10) - open_data = np.arange(100, 110) - high = np.arange(105, 115) - low = np.arange(95, 105) - close = np.arange(102, 112) - overlays = { - "SMA20": np.arange(101, 111), - "EMA10": np.arange(100.5, 110.5), - } - - dm = DataManager(index, open_data, high, low, close, overlays=overlays) - chunk = dm.get_chunk(2, 7) - - assert len(chunk["overlays"]) == 2 - assert "SMA20" in chunk["overlays"] - assert "EMA10" in chunk["overlays"] - assert chunk["overlays"]["SMA20"] == [103, 104, 105, 106, 107] - assert len(chunk["overlays"]["EMA10"]) == 5 - - def test_chunk_with_subplots(self): - """Test chunk includes subplot data.""" - index = np.arange(10) - open_data = np.arange(100, 110) - high = np.arange(105, 115) - low = np.arange(95, 105) - close = np.arange(102, 112) - subplots = { - "Volume": np.arange(1000, 1010) * 100, - "RSI": np.arange(50, 60), - } - - dm = DataManager(index, open_data, high, low, close, subplots=subplots) - chunk = dm.get_chunk(1, 6) - - assert len(chunk["subplots"]) == 2 - assert "Volume" in chunk["subplots"] - assert "RSI" in chunk["subplots"] - assert len(chunk["subplots"]["Volume"]) == 5 - assert chunk["subplots"]["RSI"] == [51, 52, 53, 54, 55] - - def test_chunk_json_serializable(self): - """Test that chunk output is JSON serializable.""" - import json - - index = np.arange(5) - open_data = np.arange(100, 105) - high = np.arange(105, 110) - low = np.arange(95, 100) - close = np.arange(102, 107) - - dm = DataManager(index, open_data, high, low, close) - chunk = dm.get_chunk(0, 5) - - # Should not raise any exception - json_str = json.dumps(chunk) - assert isinstance(json_str, str) - - # Verify round-trip - recovered = json.loads(json_str) - assert recovered["index"] == chunk["index"] - assert recovered["open"] == chunk["open"] - - def test_performance_large_dataset(self): - """Test performance with large dataset (100k points).""" - n = 100000 - index = np.arange(n) - open_data = np.random.uniform(100, 200, n) - high = open_data + np.random.uniform(0, 10, n) - low = open_data - np.random.uniform(0, 10, n) - close = np.random.uniform(low, high) - - dm = DataManager(index, open_data, high, low, close) - - # Measure slicing performance - start_time = time.time() - chunk = dm.get_chunk(10000, 20000) # 10k points - elapsed_ms = (time.time() - start_time) * 1000 - - # Should be well under 100ms for 10k points - assert elapsed_ms < 100, f"Slicing took {elapsed_ms:.2f}ms, expected <100ms" - assert len(chunk["index"]) == 10000 - - def test_performance_small_slice_large_dataset(self): - """Test performance of small slice from large dataset.""" - n = 100000 - index = np.arange(n) - open_data = np.random.uniform(100, 200, n) - high = open_data + np.random.uniform(0, 10, n) - low = open_data - np.random.uniform(0, 10, n) - close = np.random.uniform(low, high) - - dm = DataManager(index, open_data, high, low, close) - - # Small slice should be extremely fast - start_time = time.time() - chunk = dm.get_chunk(50000, 50100) # Just 100 points - elapsed_ms = (time.time() - start_time) * 1000 - - # Should be very fast (< 10ms) - assert elapsed_ms < 10, f"Small slice took {elapsed_ms:.2f}ms, expected <10ms" - assert len(chunk["index"]) == 100 - - def test_chunk_data_types(self): - """Test that chunk returns proper Python types (not numpy).""" - index = np.arange(5) - open_data = np.array([100.5, 102.3, 101.7, 103.2, 102.8]) - high = np.array([105.1, 106.2, 105.4, 107.3, 106.9]) - low = np.array([99.2, 100.1, 99.5, 101.3, 100.7]) - close = np.array([104.3, 103.8, 104.2, 105.6, 104.9]) - - dm = DataManager(index, open_data, high, low, close) - chunk = dm.get_chunk(0, 3) - - # All values should be Python lists, not numpy arrays - assert isinstance(chunk["index"], list) - assert isinstance(chunk["open"], list) - assert isinstance(chunk["high"], list) - assert isinstance(chunk["low"], list) - assert isinstance(chunk["close"], list) - - # Individual values should be Python numbers - assert isinstance(chunk["open"][0], (int, float)) diff --git a/tests/test_import.py b/tests/test_import.py deleted file mode 100644 index 15485b7..0000000 --- a/tests/test_import.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Basic import tests for PyCharting package.""" - - -def test_package_import(): - """Test that the main package can be imported.""" - import pycharting - - assert isinstance(pycharting.__version__, str) - assert pycharting.__version__ - - -def test_core_import(): - """Test that core module can be imported.""" - from pycharting import core - - assert core is not None - - -def test_data_import(): - """Test that data module can be imported.""" - from pycharting import data - - assert data is not None - - -def test_api_import(): - """Test that api module can be imported.""" - from pycharting import api - - assert api is not None - - -def test_web_import(): - """Test that web module can be imported.""" - from pycharting import web - - assert web is not None - - -def test_dependencies_available(): - """Test that core dependencies are available.""" - import fastapi - import numpy as np - import pandas as pd - import uvicorn - - assert pd is not None - assert np is not None - assert fastapi is not None - assert uvicorn is not None diff --git a/tests/test_interface.py b/tests/test_interface.py deleted file mode 100644 index a60f680..0000000 --- a/tests/test_interface.py +++ /dev/null @@ -1,406 +0,0 @@ -"""Tests for Python API interface.""" - -from unittest.mock import patch - -import numpy as np -import pytest - -from pycharting.api.interface import _repr_html_, get_server_status, plot, stop_server -from pycharting.api.routes import _data_managers - - -@pytest.fixture(autouse=True) -def cleanup_globals(): - """Clean up global state between tests. - - Operates on the live ``interface`` module global rather than a name - imported into this module — a plain ``global _active_server`` here would - rebind the test module's own copy and leave the running server (and the - real ``interface._active_server``) untouched, leaking state between tests. - """ - import pycharting.api.interface as iface - - def _reset() -> None: - if iface._active_server and iface._active_server.is_running: - iface._active_server.stop_server() - iface._active_server = None - _data_managers.clear() - - _reset() - yield - _reset() - - -class TestPlotFunction: - """Tests for the main plot() function.""" - - def test_plot_basic_usage(self): - """Test basic plot creation with minimal arguments.""" - # Generate sample data - n = 100 - index = np.arange(n) - close = np.cumsum(np.random.randn(n)) + 100 - open_data = close + np.random.randn(n) * 0.5 - high = np.maximum(open_data, close) + np.abs(np.random.randn(n)) - low = np.minimum(open_data, close) - np.abs(np.random.randn(n)) - - # Create chart without opening browser - result = plot(index, open_data, high, low, close, open_browser=False, block=False) - - assert result["status"] == "success" - assert "url" in result - assert result["data_points"] == n - assert result["server_running"] is True - assert "default" in result["session_id"] - - def test_plot_with_custom_session(self): - """Test plot with custom session ID.""" - n = 50 - index = np.arange(n) - data = np.random.randn(n) + 100 - - result = plot( - index, data, data + 1, data - 1, data, session_id="custom_session", open_browser=False, block=False - ) - - assert result["status"] == "success" - assert result["session_id"] == "custom_session" - assert "custom_session" in _data_managers - - def test_plot_with_overlays(self): - """Test plot with overlay data.""" - n = 100 - index = np.arange(n) - close = np.cumsum(np.random.randn(n)) + 100 - open_data = close + np.random.randn(n) * 0.5 - high = np.maximum(open_data, close) + np.abs(np.random.randn(n)) - low = np.minimum(open_data, close) - np.abs(np.random.randn(n)) - - # Add moving average overlay - ma = np.convolve(close, np.ones(10) / 10, mode="same") - - result = plot(index, open_data, high, low, close, overlays={"MA10": ma}, open_browser=False, block=False) - - assert result["status"] == "success" - # Check that data manager has overlay - dm = _data_managers["default"] - assert "MA10" in dm.overlays - - def test_plot_reuses_server(self): - """Test that multiple plots reuse the same server.""" - n = 50 - data = np.random.randn(n) + 100 - index = np.arange(n) - - # First plot - result1 = plot(index, data, data + 1, data - 1, data, session_id="session1", open_browser=False, block=False) - - first_server_url = result1["server_url"] - - # Second plot should reuse server - result2 = plot(index, data, data + 1, data - 1, data, session_id="session2", open_browser=False, block=False) - - assert result2["server_url"] == first_server_url - assert result1["status"] == "success" - assert result2["status"] == "success" - - def test_plot_with_invalid_data(self): - """Test plot with invalid data returns error.""" - # Try with mismatched array lengths - result = plot( - np.arange(10), - np.random.randn(10), - np.random.randn(10), - np.random.randn(10), - np.random.randn(5), # Wrong length! - open_browser=False, - block=False, - ) - - assert result["status"] == "error" - assert "error" in result - - @patch("webbrowser.open") - def test_plot_opens_browser(self, mock_browser): - """Test that plot opens browser when requested.""" - n = 50 - data = np.random.randn(n) + 100 - index = np.arange(n) - - result = plot(index, data, data + 1, data - 1, data, open_browser=True, block=False) - - assert result["status"] == "success" - # Check that browser.open was called - mock_browser.assert_called_once() - call_args = mock_browser.call_args[0][0] - assert "http://" in call_args - - @patch("webbrowser.open", side_effect=Exception("Browser error")) - def test_plot_handles_browser_error(self, mock_browser): - """Test that plot handles browser opening errors gracefully.""" - n = 50 - data = np.random.randn(n) + 100 - index = np.arange(n) - - # Should still succeed even if browser fails - result = plot(index, data, data + 1, data - 1, data, open_browser=True, block=False) - - assert result["status"] == "success" - assert result["server_running"] is True - - -class TestStopServer: - """Tests for stop_server() function.""" - - def test_stop_server_when_running(self): - """Test stopping an active server.""" - # Start a server first - n = 50 - data = np.random.randn(n) + 100 - index = np.arange(n) - - plot(index, data, data + 1, data - 1, data, open_browser=False, block=False) - - # Now stop it - stop_server() - - status = get_server_status() - assert status["running"] is False - - def test_stop_server_when_not_running(self, capsys): - """Test stopping when no server is active reports the no-op message.""" - import pycharting.api.interface as iface - - # Force the module global to None so the "no active server" branch is - # exercised deterministically, regardless of test execution order / - # xdist worker state. - original = iface._active_server - iface._active_server = None - try: - stop_server() # Should not raise; should emit the info message - assert "No active server to stop" in capsys.readouterr().out - finally: - iface._active_server = original - - -class TestGetServerStatus: - """Tests for get_server_status() function.""" - - def test_status_when_no_server(self): - """Test status when no server has been started.""" - status = get_server_status() - - assert status["running"] is False - # server_info may exist from previous server, just check it's not running - assert status["active_sessions"] == 0 - - def test_status_when_server_running(self): - """Test status when server is active.""" - n = 50 - data = np.random.randn(n) + 100 - index = np.arange(n) - - # Start server - plot(index, data, data + 1, data - 1, data, open_browser=False, block=False) - - status = get_server_status() - - assert status["running"] is True - assert status["server_info"] is not None - assert status["active_sessions"] >= 1 - assert "host" in status["server_info"] - assert "port" in status["server_info"] - - -class TestDataTypes: - """Tests for different data type inputs.""" - - def test_plot_with_numpy_arrays(self): - """Test plot with NumPy arrays.""" - n = 50 - index = np.arange(n) - close = np.random.randn(n) + 100 - - result = plot(index, close, close + 1, close - 1, close, open_browser=False, block=False) - - assert result["status"] == "success" - - def test_plot_with_lists(self): - """Test plot with Python lists.""" - n = 50 - index = list(range(n)) - close = [100 + i * 0.1 for i in range(n)] - - result = plot( - index, close, [c + 1 for c in close], [c - 1 for c in close], close, open_browser=False, block=False - ) - - assert result["status"] == "success" - - -class TestIntegration: - """Integration tests for complete workflows.""" - - def test_full_workflow(self): - """Test complete workflow: plot -> check status -> stop.""" - # Generate data - n = 100 - index = np.arange(n) - close = np.cumsum(np.random.randn(n)) + 100 - open_data = close + np.random.randn(n) * 0.5 - high = np.maximum(open_data, close) + np.abs(np.random.randn(n)) - low = np.minimum(open_data, close) - np.abs(np.random.randn(n)) - - # 1. Create chart - result = plot(index, open_data, high, low, close, session_id="workflow_test", open_browser=False, block=False) - assert result["status"] == "success" - - # 2. Check status - status = get_server_status() - assert status["running"] is True - assert status["active_sessions"] >= 1 - - # 3. Stop server - stop_server() - - # 4. Verify stopped - status = get_server_status() - assert status["running"] is False - - def test_multiple_sessions(self): - """Test creating multiple chart sessions.""" - n = 50 - data = np.random.randn(n) + 100 - index = np.arange(n) - - # Create multiple sessions - for i in range(3): - result = plot( - index, data, data + 1, data - 1, data, session_id=f"session_{i}", open_browser=False, block=False - ) - assert result["status"] == "success" - - # Check all sessions exist - assert len(_data_managers) >= 3 - for i in range(3): - assert f"session_{i}" in _data_managers - - -class TestReprHtml: - """Tests for _repr_html_ Jupyter integration.""" - - def test_repr_html_when_no_server(self): - """Verify _repr_html_() reports a stopped state when no server is active.""" - import pycharting.api.interface as iface - - original = iface._active_server - iface._active_server = None - try: - html = _repr_html_() - assert "Stopped" in html - finally: - iface._active_server = original - - def test_repr_html_when_server_running(self): - """Verify _repr_html_() returns embeddable HTML markup while a server is running.""" - n = 50 - data = np.random.randn(n) + 100 - index = np.arange(n) - plot(index, data, data + 1, data - 1, data, open_browser=False, block=False) - html = _repr_html_() - assert html is not None - assert " 0 - - def test_raises_on_no_free_port(self): - """Test that RuntimeError is raised when no port is free.""" - import socket - - # Bind on the same interface find_free_port scans ("127.0.0.1"). - # On Windows, 127.0.0.1:port does not conflict with 0.0.0.0:port, so the - # occupied port must be claimed on the same address to be seen as taken. - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - occupied_port = s.getsockname()[1] - with pytest.raises(RuntimeError, match="No free port found"): - find_free_port(occupied_port, occupied_port + 1) - - -class TestAppCreation: - """Tests for create_app function.""" - - def test_app_created(self, client): - """Test that app is created successfully.""" - assert client is not None - - def test_app_has_cors(self, client): - """Test that CORS middleware is configured.""" - response = client.options("/health") - # CORS headers should be present - assert response.status_code in [200, 405] # OPTIONS might not be explicitly handled - - -class TestRootEndpoint: - """Tests for root endpoint.""" - - def test_root_returns_html(self, client): - """Test that root endpoint returns HTML.""" - response = client.get("/") - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - assert "PyCharting" in response.text - - def test_root_has_welcome_message(self, client): - """Test that root page has welcome message.""" - response = client.get("/") - assert "Welcome to PyCharting" in response.text - assert "server is running successfully" in response.text - - def test_root_has_api_docs_link(self, client): - """Test that root page links to API docs.""" - response = client.get("/") - assert "/api/docs" in response.text - - -class TestHealthCheck: - """Tests for health check endpoint.""" - - def test_health_check_returns_200(self, client): - """Test that health check returns 200.""" - response = client.get("/health") - assert response.status_code == 200 - - def test_health_check_returns_json(self, client): - """Test that health check returns JSON.""" - response = client.get("/health") - assert response.headers["content-type"] == "application/json" - - def test_health_check_has_status(self, client): - """Test that health check includes status.""" - response = client.get("/health") - data = response.json() - assert "status" in data - assert data["status"] == "healthy" - - def test_health_check_has_service_name(self, client): - """Test that health check includes service name.""" - response = client.get("/health") - data = response.json() - assert "service" in data - assert data["service"] == "pycharting" - - -class TestStaticFiles: - """Tests for static file serving.""" - - def test_static_route_exists(self, client): - """Test that static route is mounted.""" - # This will return 404 if no file exists, but route should be there - response = client.get("/static/nonexistent.js") - # Should return 404, not 405 or other routing error - assert response.status_code == 404 - - -class TestAPIDocumentation: - """Tests for API documentation endpoints.""" - - def test_openapi_json_available(self, client): - """Test that OpenAPI JSON is available.""" - response = client.get("/openapi.json") - assert response.status_code == 200 - assert response.headers["content-type"] == "application/json" - - def test_openapi_has_title(self, client): - """Test that OpenAPI spec has correct title.""" - response = client.get("/openapi.json") - data = response.json() - assert data["info"]["title"] == "PyCharting" - - def test_openapi_has_version(self, client): - """Test that OpenAPI spec has version.""" - response = client.get("/openapi.json") - data = response.json() - assert data["info"]["version"] == "0.1.0" - - def test_docs_endpoint_available(self, client): - """Test that docs UI is available.""" - response = client.get("/api/docs") - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - - def test_redoc_endpoint_available(self, client): - """Test that ReDoc UI is available.""" - response = client.get("/api/redoc") - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - - -class TestErrorHandling: - """Tests for error handling.""" - - def test_404_on_nonexistent_route(self, client): - """Test that 404 is returned for nonexistent routes.""" - response = client.get("/nonexistent") - assert response.status_code == 404 - - def test_404_returns_json(self, client): - """Test that 404 error returns JSON.""" - response = client.get("/api/nonexistent") - # Might return HTML for non-API routes, but should handle gracefully - assert response.status_code == 404 - - -class TestCORS: - """Tests for CORS configuration.""" - - def test_cors_headers_on_health_check(self, client): - """Test that CORS headers are present.""" - response = client.get("/health", headers={"Origin": "http://localhost:3000"}) - # CORS middleware should add headers - assert response.status_code == 200 - # FastAPI's TestClient might not fully simulate CORS, but endpoint should work - - def test_endpoint_accessible(self, client): - """Test that endpoints are accessible (CORS doesn't block).""" - response = client.get("/health") - assert response.status_code == 200 - - -class TestAppMetadata: - """Tests for app metadata.""" - - def test_app_has_correct_metadata(self, client): - """Test that app metadata is correct.""" - response = client.get("/openapi.json") - data = response.json() - - assert data["info"]["title"] == "PyCharting" - assert data["info"]["description"] == "Interactive charting and data visualization API" - assert data["info"]["version"] == "0.1.0" - - -class TestNoCacheStaticFiles: - """Tests for NoCacheStaticFiles middleware.""" - - def test_adds_no_cache_headers(self, tmp_path): - """Verify NoCacheStaticFiles serves files with no-cache headers.""" - from fastapi import FastAPI - - test_file = tmp_path / "test.txt" - test_file.write_text("hello") - - app = FastAPI() - app.mount("/static", NoCacheStaticFiles(directory=str(tmp_path)), name="static") - client = TestClient(app) - - response = client.get("/static/test.txt") - assert response.status_code == 200 - assert "no-cache" in response.headers.get("cache-control", "") - assert response.headers.get("pragma") == "no-cache" - assert response.headers.get("expires") == "0" - - -class TestRunServer: - """Tests for run_server function.""" - - def test_run_server_auto_port(self): - """Verify run_server selects a positive port automatically by default.""" - from unittest.mock import MagicMock, patch - - with patch("pycharting.core.server.uvicorn") as mock_uvicorn: - mock_uvicorn.run = MagicMock() - run_server() - mock_uvicorn.run.assert_called_once() - assert mock_uvicorn.run.call_args.kwargs["port"] > 0 - - def test_run_server_specific_port(self): - """Verify run_server uses the given port when auto_port is disabled.""" - import socket - from unittest.mock import MagicMock, patch - - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("", 0)) - free_port = s.getsockname()[1] - with patch("pycharting.core.server.uvicorn") as mock_uvicorn: - mock_uvicorn.run = MagicMock() - run_server(port=free_port, auto_port=False) - assert mock_uvicorn.run.call_args.kwargs["port"] == free_port - - def test_run_server_auto_port_fallback(self): - """Verify run_server falls back to a free port when the requested port is occupied.""" - import socket - from unittest.mock import MagicMock, patch - - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - occupied = s.getsockname()[1] - fallback = occupied + 1 - with ( - patch("pycharting.core.server.uvicorn") as mock_uvicorn, - patch("pycharting.core.server.find_free_port", return_value=fallback) as mock_ffp, - ): - mock_uvicorn.run = MagicMock() - run_server(host="127.0.0.1", port=occupied, auto_port=True) - mock_ffp.assert_called_once_with(occupied + 1) - assert mock_uvicorn.run.call_args.kwargs["port"] == fallback