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