mirror of
https://github.com/simonw/datasette-vega.git
synced 2026-07-19 07:24:34 +02:00
Compare commits
3 commits
master
...
claude/mod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
602f6201b3 |
||
|
|
6c583c91a5 |
||
|
|
8849ce9f05 |
13 changed files with 19944 additions and 121 deletions
37
.travis.yml
37
.travis.yml
|
|
@ -1,37 +0,0 @@
|
|||
language: node_js
|
||||
|
||||
node_js:
|
||||
- "stable"
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
||||
|
||||
script:
|
||||
- CI=true npm test
|
||||
|
||||
jobs:
|
||||
include:
|
||||
- stage: deploy datasette-vega-latest.datasette.io
|
||||
on: master
|
||||
script:
|
||||
- npm install -g now
|
||||
- REACT_APP_STAGE=dev npm run build
|
||||
- echo '{"name":"datasette-vega-latest","alias":"datasette-vega-latest.datasette.io"}' > build/now.json
|
||||
- cd build && now --name=datasette-vega-latest --token=$NOW_TOKEN
|
||||
- now alias --token=$NOW_TOKEN
|
||||
- stage: release tagged version
|
||||
if: tag IS present
|
||||
language: python
|
||||
python: 3.6
|
||||
script:
|
||||
- pip install -U pip wheel
|
||||
deploy:
|
||||
- provider: pypi
|
||||
user: simonw
|
||||
distributions: bdist_wheel
|
||||
password: ${PYPI_PASSWORD}
|
||||
on:
|
||||
branch: master
|
||||
tags: true
|
||||
repo: simonw/datasette-vega
|
||||
18013
package-lock.json
generated
Normal file
18013
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -3,11 +3,12 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-scripts": "^4.0.3",
|
||||
"vega-embed": "^6.20.5",
|
||||
"vega-lib": "^4.4.0"
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "^5.0.1",
|
||||
"vega": "^5.25.0",
|
||||
"vega-embed": "^6.22.2",
|
||||
"vega-lite": "^5.16.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "REACT_APP_STAGE=dev react-scripts start",
|
||||
|
|
|
|||
51
pyproject.toml
Normal file
51
pyproject.toml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
[project]
|
||||
name = "datasette-vega"
|
||||
version = "0.7a0"
|
||||
description = "A Datasette plugin that provides tools for generating charts using Vega"
|
||||
readme = "README.md"
|
||||
authors = [{name = "Simon Willison"}]
|
||||
license = "Apache-2.0"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
"Framework :: Datasette",
|
||||
]
|
||||
dependencies = [
|
||||
"datasette",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/simonw/datasette-vega"
|
||||
Changelog = "https://github.com/simonw/datasette-vega/releases"
|
||||
Issues = "https://github.com/simonw/datasette-vega/issues"
|
||||
CI = "https://github.com/simonw/datasette-vega/actions"
|
||||
|
||||
[project.entry-points.datasette]
|
||||
vega = "datasette_vega"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest",
|
||||
"pytest-asyncio",
|
||||
"httpx",
|
||||
"playwright",
|
||||
"pytest-playwright",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
# Run async tests before playwright tests to avoid event loop conflicts
|
||||
testpaths = ["tests"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["datasette_vega*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
datasette_vega = ["static/*.js", "static/*.css", "static/*.map"]
|
||||
72
setup.py
72
setup.py
|
|
@ -1,72 +0,0 @@
|
|||
from distutils.core import Command
|
||||
from setuptools import setup
|
||||
from subprocess import check_output
|
||||
from wheel.bdist_wheel import bdist_wheel
|
||||
import os
|
||||
|
||||
VERSION = "0.7a0"
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_long_description():
|
||||
with open(
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md"),
|
||||
encoding="utf8",
|
||||
) as fp:
|
||||
return fp.read()
|
||||
|
||||
|
||||
class BdistWheelWithBuildStatic(bdist_wheel):
|
||||
def run(self):
|
||||
self.run_command("build_static")
|
||||
return bdist_wheel.run(self)
|
||||
|
||||
|
||||
class BuildStatic(Command):
|
||||
user_options = []
|
||||
|
||||
def initialize_options(self):
|
||||
pass
|
||||
|
||||
def finalize_options(self):
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
check_output(["npm", "install"], cwd=ROOT)
|
||||
check_output(["npm", "run", "build"], cwd=ROOT)
|
||||
check_output(["mkdir", "-p", "datasette_vega/static"], cwd=ROOT)
|
||||
check_output(
|
||||
"mv build/static/js/* datasette_vega/static/", shell=True, cwd=ROOT
|
||||
)
|
||||
check_output(
|
||||
"mv build/static/css/* datasette_vega/static/", shell=True, cwd=ROOT
|
||||
)
|
||||
|
||||
|
||||
setup(
|
||||
name="datasette-vega",
|
||||
description="A Datasette plugin that provides tools for generating charts using Vega",
|
||||
long_description=get_long_description(),
|
||||
long_description_content_type="text/markdown",
|
||||
author="Simon Willison",
|
||||
url="https://github.com/simonw/datasette-vega",
|
||||
license="Apache License, Version 2.0",
|
||||
version=VERSION,
|
||||
packages=["datasette_vega"],
|
||||
entry_points={
|
||||
"datasette": ["vega = datasette_vega"],
|
||||
},
|
||||
package_data={
|
||||
"datasette_vega": [
|
||||
"static/*.js",
|
||||
"static/*.css",
|
||||
"static/*.map",
|
||||
],
|
||||
},
|
||||
cmdclass={
|
||||
"bdist_wheel": BdistWheelWithBuildStatic,
|
||||
"build_static": BuildStatic,
|
||||
},
|
||||
install_requires=["datasette"],
|
||||
extras_require={"test": ["pytest", "pytest-asyncio", "httpx"]},
|
||||
)
|
||||
|
|
@ -53,9 +53,8 @@ class DatasetteVega extends Component {
|
|||
{"value": "nominal", "name": "Category"},
|
||||
]
|
||||
onChangeSelect(name, ev) {
|
||||
ev.persist();
|
||||
window.lastEv = ev;
|
||||
this.setState({[name]: ev.target.value}, () => {
|
||||
const value = ev.target.value;
|
||||
this.setState({[name]: value}, () => {
|
||||
this.renderGraph();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import DatasetteVega from './DatasetteVega';
|
||||
|
||||
|
|
@ -55,8 +55,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
// Add _shape=array
|
||||
jsonUrl += (jsonUrl.indexOf('?') > -1) ? '&' : '?';
|
||||
jsonUrl += '_shape=array'
|
||||
ReactDOM.render(
|
||||
<DatasetteVega base_url={jsonUrl} onFragmentChange={onFragmentChange} />, visTool
|
||||
const root = createRoot(visTool);
|
||||
root.render(
|
||||
<DatasetteVega base_url={jsonUrl} onFragmentChange={onFragmentChange} />
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
50
tests/conftest.py
Normal file
50
tests/conftest.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
STATIC_DIR = os.path.join(ROOT, "datasette_vega", "static")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def build_static_assets():
|
||||
"""
|
||||
Session-scoped fixture that builds the React app and copies
|
||||
the static assets to the datasette_vega/static directory.
|
||||
|
||||
This fixture runs npm install and npm run build, then moves
|
||||
the compiled JS and CSS files to the plugin's static directory.
|
||||
"""
|
||||
# Check if static assets already exist (e.g., in CI where they're pre-built)
|
||||
js_files = list(
|
||||
f for f in os.listdir(STATIC_DIR) if f.endswith(".js")
|
||||
) if os.path.exists(STATIC_DIR) else []
|
||||
|
||||
if js_files:
|
||||
# Assets already built, skip
|
||||
yield STATIC_DIR
|
||||
return
|
||||
|
||||
# Build the React app
|
||||
subprocess.check_call(["npm", "install"], cwd=ROOT)
|
||||
subprocess.check_call(["npm", "run", "build"], cwd=ROOT)
|
||||
|
||||
# Create static directory if it doesn't exist
|
||||
os.makedirs(STATIC_DIR, exist_ok=True)
|
||||
|
||||
# Move built assets to static directory
|
||||
build_js_dir = os.path.join(ROOT, "build", "static", "js")
|
||||
build_css_dir = os.path.join(ROOT, "build", "static", "css")
|
||||
|
||||
for filename in os.listdir(build_js_dir):
|
||||
src = os.path.join(build_js_dir, filename)
|
||||
dst = os.path.join(STATIC_DIR, filename)
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
for filename in os.listdir(build_css_dir):
|
||||
src = os.path.join(build_css_dir, filename)
|
||||
dst = os.path.join(STATIC_DIR, filename)
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
yield STATIC_DIR
|
||||
453
tests/test_playwright.py
Normal file
453
tests/test_playwright.py
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
"""
|
||||
Comprehensive Playwright tests for datasette-vega plugin.
|
||||
|
||||
These tests verify the browser-based functionality of the Vega charting
|
||||
interface, including chart rendering, UI interactions, and state persistence.
|
||||
|
||||
Uses Playwright's async API for better compatibility with pytest-asyncio.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from playwright.async_api import async_playwright, expect
|
||||
|
||||
# Skip all tests if playwright is not available
|
||||
pytest.importorskip("playwright")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def test_db_path(build_static_assets):
|
||||
"""Create a temporary database with test data for playwright tests."""
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
db_path = os.path.join(tmp_dir, "test.db")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("""
|
||||
CREATE TABLE test_data (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
value REAL,
|
||||
category TEXT,
|
||||
date TEXT
|
||||
)
|
||||
""")
|
||||
# Insert test data suitable for charting
|
||||
test_rows = [
|
||||
(1, "Alpha", 10.5, "A", "2024-01-01"),
|
||||
(2, "Beta", 20.3, "B", "2024-01-02"),
|
||||
(3, "Gamma", 15.7, "A", "2024-01-03"),
|
||||
(4, "Delta", 25.1, "B", "2024-01-04"),
|
||||
(5, "Epsilon", 30.2, "A", "2024-01-05"),
|
||||
(6, "Zeta", 12.8, "C", "2024-01-06"),
|
||||
(7, "Eta", 18.4, "C", "2024-01-07"),
|
||||
(8, "Theta", 22.6, "A", "2024-01-08"),
|
||||
(9, "Iota", 28.9, "B", "2024-01-09"),
|
||||
(10, "Kappa", 35.0, "C", "2024-01-10"),
|
||||
]
|
||||
conn.executemany(
|
||||
"INSERT INTO test_data (id, name, value, category, date) VALUES (?, ?, ?, ?, ?)",
|
||||
test_rows
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
yield db_path
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def datasette_server(test_db_path):
|
||||
"""Start a Datasette server for playwright tests."""
|
||||
import threading
|
||||
import time
|
||||
import socket
|
||||
from datasette.app import Datasette
|
||||
import uvicorn
|
||||
|
||||
ds = Datasette([test_db_path])
|
||||
app = ds.app()
|
||||
|
||||
# Find a free port
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.bind(('localhost', 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
|
||||
# Run uvicorn in a thread
|
||||
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
# Wait for server to be ready
|
||||
max_retries = 30
|
||||
for _ in range(max_retries):
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
result = sock.connect_ex(('127.0.0.1', port))
|
||||
sock.close()
|
||||
if result == 0:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def browser():
|
||||
"""Create a browser instance for tests."""
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch()
|
||||
yield browser
|
||||
await browser.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def page(browser):
|
||||
"""Create a new page for each test."""
|
||||
page = await browser.new_page()
|
||||
yield page
|
||||
await page.close()
|
||||
|
||||
|
||||
class TestVegaPluginUI:
|
||||
"""Tests for the basic Vega plugin UI elements."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_show_charting_options_button_visible(self, page, datasette_server: str):
|
||||
"""Test that the 'Show charting options' button is visible on table pages."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
|
||||
button = page.locator("button:has-text('Show charting options')").first
|
||||
await expect(button).to_be_visible()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_charting_options_initially_hidden(self, page, datasette_server: str):
|
||||
"""Test that charting options are hidden before clicking the button."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
|
||||
form = page.locator("form#graphForm")
|
||||
await expect(form).not_to_be_visible()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_show_charting_options_reveals_form(self, page, datasette_server: str):
|
||||
"""Test that clicking the button shows the charting form."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
|
||||
form = page.locator("form#graphForm")
|
||||
await expect(form).to_be_visible()
|
||||
|
||||
await expect(page.locator("h3:has-text('Charting options')")).to_be_visible()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chart_type_radio_buttons(self, page, datasette_server: str):
|
||||
"""Test that chart type radio buttons are present and functional."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
|
||||
bar_radio = page.locator("input[type='radio'][value='bar']")
|
||||
line_radio = page.locator("input[type='radio'][value='line']")
|
||||
circle_radio = page.locator("input[type='radio'][value='circle']")
|
||||
|
||||
await expect(bar_radio).to_be_visible()
|
||||
await expect(line_radio).to_be_visible()
|
||||
await expect(circle_radio).to_be_visible()
|
||||
|
||||
await expect(page.locator("label:has-text('Bar')")).to_be_visible()
|
||||
await expect(page.locator("label:has-text('Line')")).to_be_visible()
|
||||
await expect(page.locator("label:has-text('Scatter')")).to_be_visible()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_column_dropdowns_populated(self, page, datasette_server: str):
|
||||
"""Test that column dropdown menus are populated with table columns."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
|
||||
x_column_select = page.locator("select[name='x_column']")
|
||||
await expect(x_column_select).to_be_visible()
|
||||
|
||||
options = await x_column_select.locator("option").all_text_contents()
|
||||
assert "id" in options
|
||||
assert "name" in options
|
||||
assert "value" in options
|
||||
assert "category" in options
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_type_dropdowns_have_options(self, page, datasette_server: str):
|
||||
"""Test that type dropdown menus have the expected options."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
|
||||
x_type_select = page.locator("select[name='x_type']")
|
||||
await expect(x_type_select).to_be_visible()
|
||||
|
||||
options = await x_type_select.locator("option").all_text_contents()
|
||||
assert "Numeric" in options
|
||||
assert "Label" in options
|
||||
assert "Category" in options
|
||||
assert "Date/time" in options
|
||||
|
||||
|
||||
class TestChartRendering:
|
||||
"""Tests for chart rendering functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bar_chart_renders(self, page, datasette_server: str):
|
||||
"""Test that selecting bar chart renders a visualization."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
|
||||
await page.click("input[type='radio'][value='bar']")
|
||||
|
||||
await page.wait_for_timeout(1000)
|
||||
|
||||
vega_container = page.locator(".vega-embed")
|
||||
await expect(vega_container).to_be_visible()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_line_chart_renders(self, page, datasette_server: str):
|
||||
"""Test that selecting line chart renders a visualization."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
|
||||
await page.click("input[type='radio'][value='line']")
|
||||
|
||||
await page.wait_for_timeout(1000)
|
||||
|
||||
vega_container = page.locator(".vega-embed")
|
||||
await expect(vega_container).to_be_visible()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scatter_chart_renders(self, page, datasette_server: str):
|
||||
"""Test that selecting scatter chart renders a visualization."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
|
||||
await page.click("input[type='radio'][value='circle']")
|
||||
|
||||
await page.wait_for_timeout(1000)
|
||||
|
||||
vega_container = page.locator(".vega-embed")
|
||||
await expect(vega_container).to_be_visible()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chart_updates_on_column_change(self, page, datasette_server: str):
|
||||
"""Test that changing column selection updates the chart."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
await page.click("input[type='radio'][value='bar']")
|
||||
|
||||
await page.wait_for_timeout(500)
|
||||
|
||||
await page.select_option("select[name='y_column']", "value")
|
||||
|
||||
await page.wait_for_timeout(500)
|
||||
|
||||
vega_container = page.locator(".vega-embed")
|
||||
await expect(vega_container).to_be_visible()
|
||||
|
||||
|
||||
class TestSwapAxes:
|
||||
"""Tests for the swap X/Y axis functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_button_exists(self, page, datasette_server: str):
|
||||
"""Test that the swap X and Y button exists."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
|
||||
swap_button = page.locator("button:has-text('Swap X and Y')")
|
||||
await expect(swap_button).to_be_visible()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_axes_changes_selections(self, page, datasette_server: str):
|
||||
"""Test that swapping axes actually switches X and Y column selections."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
|
||||
await page.select_option("select[name='x_column']", "id")
|
||||
await page.select_option("select[name='y_column']", "value")
|
||||
|
||||
x_initial = await page.locator("select[name='x_column']").input_value()
|
||||
y_initial = await page.locator("select[name='y_column']").input_value()
|
||||
|
||||
await page.click("button:has-text('Swap X and Y')")
|
||||
|
||||
await page.wait_for_timeout(300)
|
||||
|
||||
x_after = await page.locator("select[name='x_column']").input_value()
|
||||
y_after = await page.locator("select[name='y_column']").input_value()
|
||||
|
||||
assert x_after == y_initial
|
||||
assert y_after == x_initial
|
||||
|
||||
|
||||
class TestColorAndSize:
|
||||
"""Tests for color and size encoding options."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_color_dropdown_has_none_option(self, page, datasette_server: str):
|
||||
"""Test that color dropdown includes a 'none' option."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
|
||||
color_select = page.locator("select[name='color_column']")
|
||||
await expect(color_select).to_be_visible()
|
||||
|
||||
options = await color_select.locator("option").all_text_contents()
|
||||
assert "-- none --" in options
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_size_dropdown_has_none_option(self, page, datasette_server: str):
|
||||
"""Test that size dropdown includes a 'none' option."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
|
||||
size_select = page.locator("select[name='size_column']")
|
||||
await expect(size_select).to_be_visible()
|
||||
|
||||
options = await size_select.locator("option").all_text_contents()
|
||||
assert "-- none --" in options
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_color_encoding_applied(self, page, datasette_server: str):
|
||||
"""Test that selecting a color column updates the chart."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
await page.click("input[type='radio'][value='circle']")
|
||||
|
||||
await page.wait_for_timeout(500)
|
||||
|
||||
await page.select_option("select[name='color_column']", "category")
|
||||
|
||||
await page.wait_for_timeout(500)
|
||||
|
||||
vega_container = page.locator(".vega-embed")
|
||||
await expect(vega_container).to_be_visible()
|
||||
|
||||
|
||||
class TestURLStatePersistence:
|
||||
"""Tests for URL fragment state persistence."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chart_state_saved_in_url_hash(self, page, datasette_server: str):
|
||||
"""Test that chart configuration is saved in URL hash."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
|
||||
await page.click("input[type='radio'][value='bar']")
|
||||
|
||||
await page.wait_for_timeout(500)
|
||||
|
||||
url = page.url
|
||||
assert "#" in url
|
||||
assert "g.mark=bar" in url
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chart_state_restored_from_url(self, page, datasette_server: str):
|
||||
"""Test that chart configuration is restored from URL hash."""
|
||||
await page.goto(
|
||||
f"{datasette_server}/test/test_data#g.mark=line&g.x_column=id&g.y_column=value"
|
||||
)
|
||||
|
||||
await page.wait_for_timeout(1000)
|
||||
|
||||
form = page.locator("form#graphForm").first
|
||||
await expect(form).to_be_visible()
|
||||
|
||||
line_radio = page.locator("input[type='radio'][value='line']").first
|
||||
await expect(line_radio).to_be_checked()
|
||||
|
||||
vega_container = page.locator(".vega-embed").first
|
||||
await expect(vega_container).to_be_visible()
|
||||
|
||||
|
||||
class TestNoVegaOnNonTablePages:
|
||||
"""Tests to verify Vega plugin doesn't appear on non-table pages."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_vega_on_database_page(self, page, datasette_server: str):
|
||||
"""Test that Vega button doesn't appear on database page."""
|
||||
await page.goto(f"{datasette_server}/test")
|
||||
|
||||
await page.wait_for_load_state("networkidle")
|
||||
|
||||
button = page.locator("button:has-text('Show charting options')")
|
||||
await expect(button).not_to_be_visible()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_vega_on_index_page(self, page, datasette_server: str):
|
||||
"""Test that Vega button doesn't appear on index page."""
|
||||
await page.goto(f"{datasette_server}/")
|
||||
|
||||
await page.wait_for_load_state("networkidle")
|
||||
|
||||
button = page.locator("button:has-text('Show charting options')")
|
||||
await expect(button).not_to_be_visible()
|
||||
|
||||
|
||||
class TestChartTypeOptions:
|
||||
"""Tests for different type options in X and Y dropdowns."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_numeric_binned_option(self, page, datasette_server: str):
|
||||
"""Test that 'Numeric, binned' option works correctly."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
await page.click("input[type='radio'][value='bar']")
|
||||
|
||||
await page.select_option("select[name='x_type']", "quantitative-bin")
|
||||
|
||||
await page.wait_for_timeout(500)
|
||||
|
||||
vega_container = page.locator(".vega-embed")
|
||||
await expect(vega_container).to_be_visible()
|
||||
|
||||
assert "g.x_type=quantitative-bin" in page.url
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporal_type_option(self, page, datasette_server: str):
|
||||
"""Test that temporal type option works with date column."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
await page.click("button:has-text('Show charting options')")
|
||||
await page.click("input[type='radio'][value='line']")
|
||||
|
||||
await page.select_option("select[name='x_column']", "date")
|
||||
await page.select_option("select[name='x_type']", "temporal")
|
||||
await page.select_option("select[name='y_column']", "value")
|
||||
|
||||
await page.wait_for_timeout(500)
|
||||
|
||||
vega_container = page.locator(".vega-embed")
|
||||
await expect(vega_container).to_be_visible()
|
||||
|
||||
|
||||
class TestVegaStaticAssets:
|
||||
"""Tests for static asset loading."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_css_loaded(self, page, datasette_server: str):
|
||||
"""Test that datasette-vega CSS is loaded."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
|
||||
css_links = page.locator("link[href*='datasette_vega'][href*='.css']")
|
||||
await expect(css_links.first).to_be_attached()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_js_loaded(self, page, datasette_server: str):
|
||||
"""Test that datasette-vega JS is loaded."""
|
||||
await page.goto(f"{datasette_server}/test/test_data")
|
||||
|
||||
js_scripts = page.locator("script[src*='datasette_vega'][src*='.js']")
|
||||
await expect(js_scripts.first).to_be_attached()
|
||||
|
|
@ -1,11 +1,118 @@
|
|||
import os
|
||||
import pytest
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datasette.app import Datasette
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
STATIC_DIR = os.path.join(ROOT, "datasette_vega", "static")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ds_instance(build_static_assets):
|
||||
"""
|
||||
Function-scoped Datasette instance with a test database.
|
||||
Depends on build_static_assets to ensure assets are compiled.
|
||||
"""
|
||||
# Create a temporary database with test data
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
db_path = os.path.join(tmp_dir, "test.db")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("""
|
||||
CREATE TABLE test_data (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
value REAL,
|
||||
category TEXT
|
||||
)
|
||||
""")
|
||||
# Insert some test data for charting
|
||||
test_rows = [
|
||||
(1, "Alpha", 10.5, "A"),
|
||||
(2, "Beta", 20.3, "B"),
|
||||
(3, "Gamma", 15.7, "A"),
|
||||
(4, "Delta", 25.1, "B"),
|
||||
(5, "Epsilon", 30.2, "A"),
|
||||
(6, "Zeta", 12.8, "C"),
|
||||
(7, "Eta", 18.4, "C"),
|
||||
(8, "Theta", 22.6, "A"),
|
||||
(9, "Iota", 28.9, "B"),
|
||||
(10, "Kappa", 35.0, "C"),
|
||||
]
|
||||
conn.executemany(
|
||||
"INSERT INTO test_data (id, name, value, category) VALUES (?, ?, ?, ?)",
|
||||
test_rows
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
datasette = Datasette([db_path])
|
||||
yield datasette
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_is_installed():
|
||||
async def test_plugin_is_installed(build_static_assets):
|
||||
ds = Datasette([], memory=True)
|
||||
response = await ds.client.get("/-/plugins.json")
|
||||
assert response.status_code == 200
|
||||
installed_plugins = {p["name"] for p in response.json()}
|
||||
assert "datasette-vega" in installed_plugins
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_static_assets_are_served(ds_instance):
|
||||
"""Test that the static JS and CSS assets are served correctly."""
|
||||
# Get the table page to check for CSS/JS URLs
|
||||
response = await ds_instance.client.get("/test/test_data")
|
||||
assert response.status_code == 200
|
||||
|
||||
html = response.text
|
||||
# Check that CSS and JS are included
|
||||
assert "/-/static-plugins/datasette_vega/" in html
|
||||
assert ".css" in html
|
||||
assert ".js" in html
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_static_plugin_files_accessible(ds_instance):
|
||||
"""Test that the static plugin files are accessible."""
|
||||
# Get list of static files
|
||||
if os.path.exists(STATIC_DIR):
|
||||
static_files = os.listdir(STATIC_DIR)
|
||||
js_files = [f for f in static_files if f.endswith(".js")]
|
||||
css_files = [f for f in static_files if f.endswith(".css")]
|
||||
|
||||
# Test that at least one JS and CSS file can be accessed
|
||||
for js_file in js_files[:1]:
|
||||
response = await ds_instance.client.get(
|
||||
f"/-/static-plugins/datasette_vega/{js_file}"
|
||||
)
|
||||
assert response.status_code == 200, f"Could not access {js_file}"
|
||||
|
||||
for css_file in css_files[:1]:
|
||||
response = await ds_instance.client.get(
|
||||
f"/-/static-plugins/datasette_vega/{css_file}"
|
||||
)
|
||||
assert response.status_code == 200, f"Could not access {css_file}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vega_only_on_table_pages(ds_instance):
|
||||
"""Test that Vega assets are only injected on table pages."""
|
||||
# Table page should have vega assets
|
||||
table_response = await ds_instance.client.get("/test/test_data")
|
||||
assert "/-/static-plugins/datasette_vega/" in table_response.text
|
||||
|
||||
# Database page should NOT have vega assets
|
||||
db_response = await ds_instance.client.get("/test")
|
||||
assert "/-/static-plugins/datasette_vega/" not in db_response.text
|
||||
|
||||
# Index page should NOT have vega assets
|
||||
index_response = await ds_instance.client.get("/")
|
||||
assert "/-/static-plugins/datasette_vega/" not in index_response.text
|
||||
|
|
|
|||
75
workflows/publish.yml
Normal file
75
workflows/publish.yml
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
name: Publish Python Package
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
- name: Build static assets
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
mkdir -p datasette_vega/static
|
||||
mv build/static/js/* datasette_vega/static/
|
||||
mv build/static/css/* datasette_vega/static/
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --group dev
|
||||
- name: Install Playwright browsers
|
||||
run: |
|
||||
uv run playwright install chromium --with-deps
|
||||
- name: Run tests
|
||||
run: |
|
||||
uv run pytest
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
environment: release
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
- name: Build static assets
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
mkdir -p datasette_vega/static
|
||||
mv build/static/js/* datasette_vega/static/
|
||||
mv build/static/css/* datasette_vega/static/
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install setuptools wheel build
|
||||
- name: Build
|
||||
run: |
|
||||
python -m build
|
||||
- name: Publish
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
40
workflows/test.yml
Normal file
40
workflows/test.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
name: Test
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
- name: Build static assets
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
mkdir -p datasette_vega/static
|
||||
mv build/static/js/* datasette_vega/static/
|
||||
mv build/static/css/* datasette_vega/static/
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --group dev
|
||||
- name: Install Playwright browsers
|
||||
run: |
|
||||
uv run playwright install chromium --with-deps
|
||||
- name: Run tests
|
||||
run: |
|
||||
uv run pytest
|
||||
Loading…
Add table
Add a link
Reference in a new issue