diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml new file mode 100644 index 0000000..1fdd9ba --- /dev/null +++ b/.github/workflows/run_tests.yml @@ -0,0 +1,59 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Test Astro Web + +on: + workflow_dispatch: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + env: + ASTRO_WEB_DATABASE_URL: "sqlite:///SIMPLE.sqlite" + ASTRO_WEB_LOOKUP_TABLES: "Publications,Telescopes,Instruments,PhotometryFilters,Versions,RegimeList,SourceTypeList,ParameterList,AssociationList,CompanionList,Modes,Filters,Citations,References,Parameters,Regimes" + ASTRO_WEB_PRIMARY_TABLE: "Sources" + ASTRO_WEB_SOURCE_COLUMN: "source" + ASTRO_WEB_FOREIGN_KEY: "source" + ASTRO_WEB_RA_COLUMN: "ra" + ASTRO_WEB_DEC_COLUMN: "dec" + ASTRO_WEB_SPECTRA_URL_COLUMN: "access_url" + strategy: + matrix: + python-version: ["3.13"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + version: "latest" + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: | + uv sync --all-extras --dev + + - name: Download database + run: | + curl -L https://github.com/SIMPLE-AstroDB/SIMPLE-binary/raw/main/SIMPLE.sqlite -o SIMPLE.sqlite + + - name: Run linter + run: | + uv run ruff check . + + - name: Test with pytest + run: | + uv run pytest --cov --cov-branch --cov-report=xml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..501d735 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# AGENTS.md - Astro Web + +## Project Overview +Astro Web is a FastAPI-based web application for exploring astronomical databases created with the AstroDB Toolkit. It features interactive Bokeh visualizations (scatter plots, spectra), source browsing with DataTables, and both text and cone search capabilities. + +## Directory Structure +- `astro_web/`: Main application package. + - `main.py`: FastAPI entry point. + - `config.py`: Configuration and environment variables. + - `database/`: Database interaction (astrodbkit, SQLite). + - `routes/`: Web page route definitions (web.py). + - `visualizations/`: Bokeh plot generation (scatter, spectra). + - `templates/`: Jinja2 HTML templates. + - `static/`: CSS and schema definitions. +- `CONFIG.md`: Configuration settings guide. +- `pyproject.toml`: Project metadata and dependencies (managed by uv). +- `.env.example`: Template for environment variables. +- `constitution.md`: Project constitution and development guidelines. + +## Guidance +Prior to making any code changes, read the `constitution.md` file to understand the project's development guidelines and coding standards. diff --git a/astro_web/config.py b/astro_web/config.py index aa37fb2..89f3544 100644 --- a/astro_web/config.py +++ b/astro_web/config.py @@ -55,6 +55,7 @@ "Versions", "Parameters", "Regimes", + "RegimeList", "ParameterList", "AssociationList", "CompanionList", diff --git a/astro_web/database/__init__.py b/astro_web/database/__init__.py index e16c76a..a8e83e6 100644 --- a/astro_web/database/__init__.py +++ b/astro_web/database/__init__.py @@ -1,3 +1,39 @@ """Database module for Astrodbkit database interactions.""" -__all__ = ["sources", "query"] +from contextlib import contextmanager +from astrodbkit.astrodb import Database +from astro_web.config import ( + CONNECTION_STRING, + FOREIGN_KEY, + LOOKUP_TABLES, + PRIMARY_TABLE, + SCHEMA, + SOURCE_COLUMN, +) + + +@contextmanager +def get_db(): + """ + Context manager for astrodbkit Database connections. + Ensures the database session is closed and engine is disposed. + """ + db = Database( + CONNECTION_STRING, + primary_table=PRIMARY_TABLE, + primary_table_key=SOURCE_COLUMN, + lookup_tables=LOOKUP_TABLES, + schema=SCHEMA, + foreign_key=FOREIGN_KEY, + ) + try: + yield db + finally: + # Close the session and dispose the engine to release the database file + if hasattr(db, "session") and db.session: + db.session.close() + if hasattr(db, "engine") and db.engine: + db.engine.dispose() + + +__all__ = ["sources", "query", "get_db"] diff --git a/astro_web/database/query.py b/astro_web/database/query.py index 07f6049..1ef4054 100644 --- a/astro_web/database/query.py +++ b/astro_web/database/query.py @@ -7,20 +7,14 @@ import time -from astrodbkit.astrodb import Database from astropy.coordinates import SkyCoord from astropy.units import Quantity from astro_web.config import ( - CONNECTION_STRING, DEC_COLUMN, - FOREIGN_KEY, - LOOKUP_TABLES, - PRIMARY_TABLE, RA_COLUMN, - SCHEMA, - SOURCE_COLUMN, ) +from astro_web.database import get_db def search_objects(query: str): @@ -36,15 +30,8 @@ def search_objects(query: str): and execution_time is the time taken in seconds """ start_time = time.time() - db = Database( - CONNECTION_STRING, - primary_table=PRIMARY_TABLE, - primary_table_key=SOURCE_COLUMN, - lookup_tables=LOOKUP_TABLES, - schema=SCHEMA, - foreign_key=FOREIGN_KEY, - ) - results = db.search_object(query.strip(), resolve_simbad=True, format="pandas") + with get_db() as db: + results = db.search_object(query.strip(), resolve_simbad=True, fmt="pandas") execution_time = time.time() - start_time return results, execution_time @@ -68,18 +55,20 @@ def parse_coordinates_string(coords_str): """ coords_str = coords_str.strip() - # Check if the string contains sexagesimal indicators (h, m, s, d, etc.) - has_sexagesimal = any(char in coords_str.lower() for char in ["h", "m", "s", "d", "°", "'", '"']) + # Check if the string contains sexagesimal indicators or has multiple parts + parts = coords_str.split() + has_sexagesimal = ( + any(char in coords_str.lower() for char in ["h", "m", "s", "d", "°", "'", '"', ":"]) or len(parts) > 2 + ) try: if has_sexagesimal: - # For sexagesimal format, SkyCoord can auto-detect - skycoord = SkyCoord(coords_str, frame="icrs") + # For sexagesimal format, assume hourangle for RA if not explicit + skycoord = SkyCoord(coords_str, frame="icrs", unit=("hour", "deg")) ra_decimal = skycoord.ra.deg dec_decimal = skycoord.dec.deg else: # For decimal format, split and parse manually - parts = coords_str.split() if len(parts) != 2: raise ValueError("Expected two space-separated values for decimal coordinates (e.g., '209.30 14.48')") @@ -153,17 +142,12 @@ def cone_search(ra, dec, radius_deg): and execution_time is the time taken in seconds """ start_time = time.time() - db = Database( - CONNECTION_STRING, - primary_table=PRIMARY_TABLE, - primary_table_key=SOURCE_COLUMN, - lookup_tables=LOOKUP_TABLES, - schema=SCHEMA, - foreign_key=FOREIGN_KEY, - ) coords = SkyCoord(ra, dec, unit="deg") radius = Quantity(radius_deg, "deg") - results = db.query_region(coords, radius=radius, fmt="pandas", ra_col=RA_COLUMN, dec_col=DEC_COLUMN) + + with get_db() as db: + results = db.query_region(coords, radius=radius, fmt="pandas", ra_col=RA_COLUMN, dec_col=DEC_COLUMN) + execution_time = time.time() - start_time # Apply 10,000 result cap if needed diff --git a/astro_web/database/sources.py b/astro_web/database/sources.py index a1063c0..6b5967b 100644 --- a/astro_web/database/sources.py +++ b/astro_web/database/sources.py @@ -2,19 +2,14 @@ import logging -from astrodbkit.astrodb import Database from specutils import Spectrum from astro_web.config import ( - CONNECTION_STRING, - FOREIGN_KEY, - LOOKUP_TABLES, PRIMARY_DATATYPE, PRIMARY_TABLE, - SCHEMA, - SOURCE_COLUMN, SPECTRA_URL_COLUMN, ) +from astro_web.database import get_db def get_all_sources(): @@ -25,15 +20,8 @@ def get_all_sources(): list: List of dictionaries representing all Sources rows, or None on error """ try: - db = Database( - CONNECTION_STRING, - primary_table=PRIMARY_TABLE, - primary_table_key=SOURCE_COLUMN, - lookup_tables=LOOKUP_TABLES, - schema=SCHEMA, - foreign_key=FOREIGN_KEY, - ) - df = db.query(db.metadata.tables[PRIMARY_TABLE]).pandas() + with get_db() as db: + df = db.query(db.metadata.tables[PRIMARY_TABLE]).pandas() return df.to_dict("records") except Exception as e: logging.error(f"Error getting all sources: {e}") @@ -52,21 +40,11 @@ def get_source_inventory(source_name): Only tables with data are returned. Empty tables are filtered out. """ try: - print(LOOKUP_TABLES) - - # Connect to database - db = Database( - CONNECTION_STRING, - primary_table=PRIMARY_TABLE, - primary_table_key=SOURCE_COLUMN, - lookup_tables=LOOKUP_TABLES, - schema=SCHEMA, - foreign_key=FOREIGN_KEY, - ) - # Get inventory (returns dict of table name -> list of dicts) source_name = PRIMARY_DATATYPE(source_name) - inventory = db.inventory(source_name) + + with get_db() as db: + inventory = db.inventory(source_name) # Filter out empty tables - only return tables that have data result = {} @@ -93,20 +71,11 @@ def get_source_spectra(source_name, convert_to_spectrum=False): instrument, etc.) or None on error """ - # Connect to database - db = Database( - CONNECTION_STRING, - primary_table=PRIMARY_TABLE, - primary_table_key=SOURCE_COLUMN, - lookup_tables=LOOKUP_TABLES, - schema=SCHEMA, - foreign_key=FOREIGN_KEY, - ) - try: # Query spectra table for the source using astrodbkit's pandas method source_name = PRIMARY_DATATYPE(source_name) - spectra_df = db.query(db.Spectra).filter(db.Spectra.c.source == source_name).pandas() + with get_db() as db: + spectra_df = db.query(db.Spectra).filter(db.Spectra.c.source == source_name).pandas() except Exception: return None diff --git a/astro_web/routes/web.py b/astro_web/routes/web.py index 50c9369..6e7202c 100644 --- a/astro_web/routes/web.py +++ b/astro_web/routes/web.py @@ -55,7 +55,7 @@ async def homepage(request: Request): # Create navigation context with active page nav_context = create_navigation_context(current_page="/") - return templates.TemplateResponse("index.html", {"request": request, **nav_context}) + return templates.TemplateResponse(request, "index.html", {"request": request, **nav_context}) async def browse(request: Request): @@ -75,6 +75,7 @@ async def browse(request: Request): nav_context = create_navigation_context(current_page="/browse") return templates.TemplateResponse( + request, "browse.html", { "request": request, @@ -95,6 +96,7 @@ async def plot(request: Request): nav_context = create_navigation_context(current_page="/plots") return templates.TemplateResponse( + request, "plot.html", { "request": request, @@ -134,6 +136,7 @@ async def inventory(request: Request, source_name: str): nav_context = create_navigation_context(current_page=f"/source/{source_name}") return templates.TemplateResponse( + request, "inventory.html", { "request": request, @@ -171,6 +174,7 @@ async def spectra_display(request: Request, source_name: str): nav_context = create_navigation_context(current_page=f"/source/{source_name}/spectra") return templates.TemplateResponse( + request, "spectra.html", { "request": request, @@ -193,7 +197,7 @@ async def search_form(request: Request): # Create navigation context with active page nav_context = create_navigation_context(current_page="/search") - return templates.TemplateResponse("search.html", {"request": request, **nav_context}) + return templates.TemplateResponse(request, "search.html", {"request": request, **nav_context}) async def search_results(request: Request, query: str = Form(...)): @@ -203,7 +207,7 @@ async def search_results(request: Request, query: str = Form(...)): if not query.strip(): nav_context = create_navigation_context(current_page="/search") return templates.TemplateResponse( - "search.html", {"request": request, "error": "Please enter a search term", **nav_context} + request, "search.html", {"request": request, "error": "Please enter a search term", **nav_context} ) # Execute search using astrodbkit @@ -219,6 +223,7 @@ async def search_results(request: Request, query: str = Form(...)): nav_context = create_navigation_context(current_page="/search") return templates.TemplateResponse( + request, "search_results.html", { "request": request, @@ -234,6 +239,7 @@ async def search_results(request: Request, query: str = Form(...)): # Handle astrodbkit errors nav_context = create_navigation_context(current_page="/search") return templates.TemplateResponse( + request, "search_results.html", { "request": request, @@ -266,6 +272,8 @@ async def search_api(query: str = Form(...)): "execution_time": execution_time, } + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=f"An error occurred during search: {e}") @@ -300,6 +308,7 @@ async def cone_search_results( formatted_results = get_source_url(formatted_results) return templates.TemplateResponse( + request, "search_results.html", { "request": request, @@ -317,10 +326,11 @@ async def cone_search_results( except ValueError as e: # Validation errors - return to search page with error - return templates.TemplateResponse("search.html", {"request": request, "error": str(e), **nav_context}) + return templates.TemplateResponse(request, "search.html", {"request": request, "error": str(e), **nav_context}) except Exception as e: # Database errors - return results page with error return templates.TemplateResponse( + request, "search_results.html", { "request": request, @@ -394,4 +404,4 @@ async def inventory_api(source_name: str = Form(...)): async def not_found(request: Request, path: str): """Render 404 error page for non-existent routes.""" - return templates.TemplateResponse("404.html", {"request": request, "path": path}, status_code=404) + return templates.TemplateResponse(request, "404.html", {"request": request, "path": path}, status_code=404) diff --git a/constitution.md b/constitution.md new file mode 100644 index 0000000..17e0ef3 --- /dev/null +++ b/constitution.md @@ -0,0 +1,83 @@ +# Astro-Web Constitution + + + +## Core Principles + +### I. FastAPI-First Architecture +Every web endpoint MUST be defined via FastAPI. Jinja2 templates MUST be used for all web page rendering. The API layer MUST be clearly separated from the presentation layer to enable future programmatic access and prototype reuse across other astronomical databases. + +### II. Astrodbkit Database Abstraction +Data access MUST use Astrodbkit for SQLite queries. Direct SQL access MUST be avoided. This ensures compatibility with astronomical data standards and facilitates portability to other database backends in future prototypes. + +### III. Bokeh for Visualizations +All data visualizations MUST be generated using Bokeh. Visualizations MUST be embedded as interactive components in Jinja2 templates. Exported visualizations MUST support PNG and HTML formats for documentation and presentations. + +### IV. CSS for Styling +Website styling MUST use CSS. CSS files MUST be kept separate from HTML templates. Inline styles and JavaScript-based styling frameworks are prohibited to maintain simplicity and facilitate maintenance by astronomers with intermediate-level skills. Use vanilla CSS or minimal CSS frameworks that do not require compilation. + +### V. Simplicity Over Elegance (NON-NEGOTIABLE) +Code MUST prioritize clarity and straightforward logic over clever optimizations. Functions MUST be kept small (<50 lines when possible). Complex abstractions and design patterns MUST be avoided unless absolutely necessary. This principle ensures astronomers with intermediate Python skills can maintain and extend the prototype. + +### VI. Prototype-Driven Development +Design decisions MUST consider this codebase as a prototype for other astronomical databases. Data models, API endpoints, and UI components MUST be designed with reusability in mind. Clear documentation of data schemas and API contracts is mandatory. + +### VII. SQLite-First Storage +The primary database will initially be SQLite for ease of deployment and distribution. Database files MUST be readable by standard SQLite tools for data inspection and backup. + +## Technology Stack Requirements + +**Backend Framework**: FastAPI ≥0.120.0 +**Template Engine**: Jinja2 (via FastAPI templates) +**Styling**: CSS (vanilla CSS or minimal frameworks) +**Database**: SQLite with Astrodbkit ≥2.4 +**Visualization**: Bokeh +**Language**: Python ≥3.13 +**Deployment**: Development server (uvicorn) for prototype phase +**Testing**: pytest for integration tests, manual testing for UI flows + +## Development Approach + +### Code Organization +- `/astro_web/main.py`: FastAPI application entry point +- `/astro_web/routes/`: API route definitions +- `/astro_web/templates/`: Jinja2 HTML templates +- `/astro_web/static/`: CSS files and static assets +- `/astro_web/database/`: Astrodbkit database connection and queries +- `/astro_web/visualizations/`: Bokeh plot generation functions +- `/tests/`: Integration tests for API endpoints + +### Documentation Standards +- All functions MUST have docstrings explaining purpose, parameters, and return values +- Data models MUST have clear comments explaining astronomical meaning +- README MUST include setup instructions and database schema overview +- API endpoints MUST be documented with clear request/response examples + +### Testing Requirements +- Integration tests MUST verify Astrodbkit queries return expected data structures +- API endpoint tests MUST verify JSON responses for API routes +- Template rendering tests MUST verify pages load without errors +- Visualizations MUST be manually verified to display correctly + +## Governance + +This constitution supersedes all other development practices. Amendments require: +1. Documentation of rationale +2. Impact assessment on prototype reusability +3. Update to version number following semantic versioning +4. Synchronization with template files + +All code reviews MUST verify compliance with these principles. Complexity beyond intermediate Python must be justified with clear comments explaining necessity. The `/speckit.plan` and `/speckit.spec` commands provide runtime development guidance for implementing features while maintaining constitution compliance. + +**Version**: 1.1.0 | **Ratified**: 2025-01-27 | **Last Amended**: 2025-01-27 diff --git a/pyproject.toml b/pyproject.toml index ba122b0..615844d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ dev = [ "pytest>=8.0.0", "ruff>=0.14.0", + "httpx>=0.28.1", + "pytest-cov>=7.1.0", ] [project.scripts] @@ -31,3 +33,23 @@ package = true [tool.setuptools.packages.find] include = ["astro_web*"] + +# coverage configuration +[tool.coverage.run] +data_file = ".coverage" +source = [ + "astro_web" +] +omit = [ + "templates/", +] + +[tool.coverage.report] +omit = ["__init__.py"] +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if __name__ == '__main__':", + "if r.status_code != 200:", + "except Exception as e:", +] diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..ea8bf86 --- /dev/null +++ b/test/README.md @@ -0,0 +1,53 @@ +# Astro-Web Tests + +This directory contains the test suite for the Astro-Web application. The tests are designed to ensure the reliability of astronomical database queries, API endpoints, and web page rendering. + +## Test Organization + +The tests are split into two main categories: + +- **`unit/`**: Isolated tests for utility functions and visualization logic that do not require a database connection. + - `database/`: Tests for coordinate parsing and unit conversions. + - `visualizations/`: Tests for Bokeh plot generation logic. +- **`integration/`**: Tests that verify the interaction between different components, including the database and the web framework. + - `api/`: Verifies RESTful API endpoints and JSON responses. + - `database/`: Verifies data retrieval using `astrodbkit` from the SQLite database. + - `web/`: Verifies that Jinja2 templates render correctly with live data. + +## Requirements + +### Database +Integration tests require a copy of the **SIMPLE** database in the project root. +- **File name**: `SIMPLE.sqlite` +- **Download**: You can download the latest version from [SIMPLE-AstroDB/SIMPLE-binary](https://github.com/SIMPLE-AstroDB/SIMPLE-binary/raw/main/SIMPLE.sqlite). + +The tests expect this file to be present at the path specified by `ASTRO_WEB_DATABASE_URL` (defaults to `sqlite:///SIMPLE.sqlite`). + +### Dependencies +All testing dependencies are included in the `dev` optional dependency group in `pyproject.toml`. + +## Running Tests + +### Using `uv` (Recommended) +To run the full test suite: +```bash +uv run pytest +``` + +To run with coverage report: +```bash +uv run pytest --cov +``` + +### Running Specific Tests +You can target specific directories or files: +```bash +# Run only unit tests +uv run pytest test/unit + +# Run only database integration tests +uv run pytest test/integration/database +``` + +## Continuous Integration +Tests are automatically run on GitHub Actions for every push and pull request to the `main` branch. The CI environment automatically downloads the required database to perform integration testing. diff --git a/test/integration/api/test_api_endpoints.py b/test/integration/api/test_api_endpoints.py new file mode 100644 index 0000000..46ff8f7 --- /dev/null +++ b/test/integration/api/test_api_endpoints.py @@ -0,0 +1,50 @@ +from fastapi.testclient import TestClient +from astro_web.main import app + +client = TestClient(app) + +def test_api_search_success(): + """POST /api/search returns 200 and expected JSON structure.""" + response = client.post("/api/search", data={"query": "2MASS J03552014+1439297"}) + assert response.status_code == 200 + data = response.json() + assert "results" in data + assert "total_count" in data + assert data["total_count"] > 0 + assert any(res["source"] == "2MASS J03552014+1439297" for res in data["results"]) + +def test_api_search_no_query(): + """POST /api/search with empty query returns 400.""" + response = client.post("/api/search", data={"query": " "}) + assert response.status_code == 400 + assert "detail" in response.json() + +def test_api_cone_search_success(): + """POST /api/search/cone returns 200 and expected JSON.""" + response = client.post("/api/search/cone", data={ + "coordinates": "58.83375 14.658056", + "radius": "0.1", + "radius_unit": "degrees" + }) + assert response.status_code == 200 + data = response.json() + assert "results" in data + assert data["total_count"] > 0 + assert any(res["source"] == "2MASS J03552014+1439297" for res in data["results"]) + +def test_api_inventory_success(): + """POST /api/inventory returns 200 and source data.""" + # Note: main.py uses 'source' as form field name, but web.py uses 'source_name' + # The endpoint in main.py is: @app.post("/api/inventory") async def inventory_api_endpoint(source: str = Form(...)) + response = client.post("/api/inventory", data={"source": "2MASS J03552014+1439297"}) + assert response.status_code == 200 + data = response.json() + assert data["source_name"] == "2MASS J03552014+1439297" + assert "inventory" in data + assert "Sources" in data["inventory"] + +def test_api_inventory_not_found(): + """POST /api/inventory for non-existent source returns 404.""" + response = client.post("/api/inventory", data={"source": "NonExistentSource"}) + assert response.status_code == 404 + assert "detail" in response.json() diff --git a/test/integration/database/test_db_queries.py b/test/integration/database/test_db_queries.py new file mode 100644 index 0000000..1ade013 --- /dev/null +++ b/test/integration/database/test_db_queries.py @@ -0,0 +1,67 @@ +import pandas as pd +from astro_web.database.sources import get_all_sources, get_source_inventory, get_source_spectra +from astro_web.database.query import search_objects, cone_search + +def test_get_all_sources(): + """Verify it returns a list of sources with expected columns.""" + sources = get_all_sources() + assert isinstance(sources, list) + assert len(sources) > 0 + # Check if 'source', 'ra', 'dec' are in the first record + first_source = sources[0] + assert "source" in first_source + assert "ra" in first_source + assert "dec" in first_source + +def test_search_objects(): + """Verify search results for known objects.""" + # Searching for a known object in the database + query_str = "2MASS J03552014+1439297" + results, exec_time = search_objects(query_str) + + assert isinstance(results, pd.DataFrame) + assert not results.empty + assert exec_time > 0 + # Check if our target is in the results + assert any(results["source"].str.contains(query_str, regex=False)) + +def test_cone_search(): + """Verify results are within the requested radius.""" + # Centered on 2MASS J03552014+1439297 (RA=58.83375, Dec=14.658056) + ra = 58.83375 + dec = 14.658056 + radius = 0.1 # 0.1 degrees + + results, exec_time = cone_search(ra, dec, radius) + + assert isinstance(results, pd.DataFrame) + assert not results.empty + assert exec_time > 0 + + # Verify we found at least the source itself + assert any(results["source"] == "2MASS J03552014+1439297") + +def test_get_source_inventory(): + """Verify all related tables are retrieved for a specific source.""" + source_name = "2MASS J03552014+1439297" + inventory = get_source_inventory(source_name) + + assert isinstance(inventory, dict) + assert len(inventory) > 0 + # Common tables that should have data for this source + assert "Sources" in inventory + assert "Spectra" in inventory + +def test_get_source_spectra(): + """Verify spectra data retrieval.""" + source_name = "2MASS J03552014+1439297" + spectra = get_source_spectra(source_name) + + assert isinstance(spectra, pd.DataFrame) + assert not spectra.empty + assert "source" in spectra.columns + assert "access_url" in spectra.columns + assert "processed_spectrum" in spectra.columns + + # Check if at least one spectrum was processed + assert spectra["processed_spectrum"].notna().any() diff --git a/test/integration/web/test_web_rendering.py b/test/integration/web/test_web_rendering.py new file mode 100644 index 0000000..196daa0 --- /dev/null +++ b/test/integration/web/test_web_rendering.py @@ -0,0 +1,54 @@ +from fastapi.testclient import TestClient +from astro_web.main import app + +client = TestClient(app) + +def test_render_homepage(): + """GET / returns 200 and navigation.""" + response = client.get("/") + assert response.status_code == 200 + assert "Home" in response.text + assert "Browse Database" in response.text + +def test_render_browse(): + """GET /browse renders source table.""" + response = client.get("/browse") + assert response.status_code == 200 + assert "Source" in response.text + assert "2MASS J03552014+1439297" in response.text + +def test_render_search_form(): + """GET /search renders search inputs.""" + response = client.get("/search") + assert response.status_code == 200 + assert "Search" in response.text + assert "Coordinates" in response.text + +def test_render_inventory_page(): + """GET /source/{source_name} renders for existing source.""" + source_name = "2MASS J03552014+1439297" + response = client.get(f"/source/{source_name}") + assert response.status_code == 200 + assert source_name in response.text + assert "Inventory" in response.text + +def test_render_inventory_404(): + """GET /source/{invalid_source} returns 404 template.""" + response = client.get("/source/NonExistentSource") + assert response.status_code == 404 + assert "Source not found" in response.text + +def test_render_spectra_page(): + """GET /source/{source_name}/spectra renders Bokeh plot components.""" + source_name = "2MASS J03552014+1439297" + response = client.get(f"/source/{source_name}/spectra") + assert response.status_code == 200 + assert "Spectra" in response.text + assert "bokeh" in response.text.lower() + +def test_render_plots_page(): + """GET /plots renders scatter plot components.""" + response = client.get("/plots") + assert response.status_code == 200 + assert "Plots" in response.text + assert "bokeh" in response.text.lower() diff --git a/test/unit/database/test_query_utils.py b/test/unit/database/test_query_utils.py new file mode 100644 index 0000000..6c539bc --- /dev/null +++ b/test/unit/database/test_query_utils.py @@ -0,0 +1,81 @@ +import pytest +from astro_web.database.query import parse_coordinates_string, convert_radius_to_degrees + +def test_parse_coordinates_string_decimal(): + """Verify correct parsing of 'RA Dec' decimal strings.""" + ra, dec = parse_coordinates_string("209.30 14.48") + assert ra == 209.30 + assert dec == 14.48 + + # Test with extra spaces + ra, dec = parse_coordinates_string(" 180.0 -45.0 ") + assert ra == 180.0 + assert dec == -45.0 + +def test_parse_coordinates_string_sexagesimal(): + """Verify correct parsing of sexagesimal strings.""" + # SkyCoord handles these, we just verify the output is as expected + ra, dec = parse_coordinates_string("13h57m12s +14d28m39s") + assert pytest.approx(ra, rel=1e-5) == 209.3 + assert pytest.approx(dec, rel=1e-5) == 14.4775 + + # Another format + ra, dec = parse_coordinates_string("13:57:12 +14:28:39") + assert pytest.approx(ra, rel=1e-5) == 209.3 + assert pytest.approx(dec, rel=1e-5) == 14.4775 + +def test_parse_coordinates_string_invalid(): + """Verify ValueError is raised for invalid formats.""" + # Wrong number of parts + with pytest.raises(ValueError, match="Expected two space-separated values"): + parse_coordinates_string("209.30") + + # Non-numeric + with pytest.raises(ValueError): + parse_coordinates_string("abc def") + + # Out of range RA + with pytest.raises(ValueError, match="RA must be between 0 and 360"): + parse_coordinates_string("361.0 10.0") + + # Out of range Dec + with pytest.raises(ValueError, match=r"Dec must be between -90 and \+90"): + parse_coordinates_string("180.0 95.0") + +def test_convert_radius_to_degrees(): + """Verify conversion from arcminutes/arcseconds to degrees.""" + # Degrees to degrees + assert convert_radius_to_degrees(1.0, "degrees") == 1.0 + + # Arcminutes to degrees + assert convert_radius_to_degrees(60.0, "arcminutes") == 1.0 + assert convert_radius_to_degrees(1.0, "arcminutes") == 1.0/60.0 + + # Arcseconds to degrees + assert convert_radius_to_degrees(3600.0, "arcseconds") == 1.0 + assert convert_radius_to_degrees(1.0, "arcseconds") == 1.0/3600.0 + + # Test with string input + assert convert_radius_to_degrees("1.5", "degrees") == 1.5 + +def test_convert_radius_validation(): + """Verify errors for negative radius or radius > 10 degrees.""" + # Negative radius + with pytest.raises(ValueError, match="Radius must be a positive number"): + convert_radius_to_degrees(-1.0, "degrees") + + # Zero radius (should be positive) + with pytest.raises(ValueError, match="Radius must be a positive number"): + convert_radius_to_degrees(0.0, "degrees") + + # Too large radius + with pytest.raises(ValueError, match="Radius must not exceed 10 degrees"): + convert_radius_to_degrees(10.1, "degrees") + + # Too large radius after conversion (e.g., 601 arcminutes) + with pytest.raises(ValueError, match="Radius must not exceed 10 degrees"): + convert_radius_to_degrees(601.0, "arcminutes") + + # Invalid unit + with pytest.raises(ValueError, match="Invalid radius unit"): + convert_radius_to_degrees(1.0, "kiloparsecs") diff --git a/test/unit/visualizations/test_vis_logic.py b/test/unit/visualizations/test_vis_logic.py new file mode 100644 index 0000000..38179d5 --- /dev/null +++ b/test/unit/visualizations/test_vis_logic.py @@ -0,0 +1,70 @@ +import pandas as pd +from astro_web.visualizations.scatter import create_scatter_plot +from astro_web.visualizations.spectra import generate_spectra_plot + +def test_create_scatter_plot_logic(): + """Verify create_scatter_plot returns dict with 'script' and 'div'.""" + plot_data = create_scatter_plot() + assert isinstance(plot_data, dict) + assert "script" in plot_data + assert "div" in plot_data + assert plot_data["script"].startswith("