diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dcc7b61 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,42 @@ +""" +Shared fixtures for iSamples website BDD tests. + +Usage: + # Against live site (default): + pytest tests/ + + # Against local Quarto preview: + ISAMPLES_BASE_URL=http://localhost:5860 pytest tests/ + + # With visible browser: + pytest tests/ --headed +""" +import os +import pytest +from playwright.sync_api import sync_playwright + + +SITE_URL = os.environ.get("ISAMPLES_BASE_URL", "https://isamples.org") + + +@pytest.fixture(scope="session") +def browser(): + with sync_playwright() as p: + browser = p.chromium.launch( + headless="--headed" not in " ".join(os.sys.argv), + ) + yield browser + browser.close() + + +@pytest.fixture +def page(browser): + context = browser.new_context(viewport={"width": 1280, "height": 900}) + page = context.new_page() + yield page + context.close() + + +@pytest.fixture(scope="session") +def site_url(): + return SITE_URL diff --git a/tests/test_explorer.py b/tests/test_explorer.py new file mode 100644 index 0000000..be3847e --- /dev/null +++ b/tests/test_explorer.py @@ -0,0 +1,83 @@ +""" +Interactive Explorer tests — verify the search/filter/globe experience. + +These tests hit the live Explorer page and wait for DuckDB-WASM to initialize. +They are slower (~30s+) due to remote parquet loading. +""" +import pytest +from conftest import SITE_URL + +EXPLORER_URL = f"{SITE_URL}/tutorials/isamples_explorer.html" + + +@pytest.fixture +def explorer_page(page): + """Navigate to Explorer and wait for initial load.""" + page.goto(EXPLORER_URL, wait_until="domcontentloaded", timeout=60000) + return page + + +class TestExplorerLoads: + """Explorer page should load and initialize DuckDB-WASM.""" + + def test_page_loads(self, explorer_page): + assert "Explorer" in explorer_page.title() + + def test_has_search_input(self, explorer_page): + # Observable inputs render after JS executes — wait for them + search = explorer_page.locator("input[type='text']") + search.first.wait_for(state="visible", timeout=15000) + assert search.count() > 0 + + def test_has_source_filter_section(self, explorer_page): + assert explorer_page.get_by_text("Source", exact=True).count() > 0 + + def test_has_material_filter_section(self, explorer_page): + assert explorer_page.get_by_text("Material", exact=True).count() > 0 + + def test_has_sampled_feature_filter(self, explorer_page): + assert explorer_page.get_by_text("Sampled Feature").count() > 0 + + def test_has_specimen_type_filter(self, explorer_page): + assert explorer_page.get_by_text("Specimen Type").count() > 0 + + def test_has_max_samples_slider(self, explorer_page): + # Observable range input renders after JS — wait for it + slider = explorer_page.locator("input[type='range']") + slider.first.wait_for(state="attached", timeout=15000) + assert slider.count() > 0 + + def test_has_view_mode_selector(self, explorer_page): + assert explorer_page.get_by_text("Globe").count() > 0 + assert explorer_page.get_by_text("List").count() > 0 + assert explorer_page.get_by_text("Table").count() > 0 + + +class TestExplorerFacetCounts: + """Facet counts should appear from pre-computed summaries.""" + + def test_source_checkboxes_have_counts(self, explorer_page): + """Source checkboxes should show sample counts (loaded from 2KB summary).""" + # Wait for facet summaries to load (they're tiny, should be fast) + explorer_page.wait_for_timeout(5000) + # Check that at least one source has a count in parentheses + sesar = explorer_page.get_by_text("SESAR") + assert sesar.count() > 0 + + def test_four_sources_present(self, explorer_page): + """All 4 data sources should appear as filter options.""" + explorer_page.wait_for_timeout(5000) + for source in ["SESAR", "OPENCONTEXT", "GEOME", "SMITHSONIAN"]: + assert explorer_page.get_by_text(source).count() > 0, f"Missing source: {source}" + + +class TestExplorerSampleCard: + """Sample Card section should exist.""" + + def test_has_sample_card_section(self, explorer_page): + assert explorer_page.get_by_text("Sample Card").count() > 0 + + def test_sample_card_shows_click_prompt(self, explorer_page): + """Before clicking a point, card should show instructions.""" + explorer_page.wait_for_timeout(3000) + assert explorer_page.get_by_text("Click a point").count() > 0 diff --git a/tests/test_key_pages.py b/tests/test_key_pages.py new file mode 100644 index 0000000..72b730f --- /dev/null +++ b/tests/test_key_pages.py @@ -0,0 +1,128 @@ +""" +Key page smoke tests — verify all important pages load and have expected content. + +These catch broken pages, missing assets, and regressions in page structure. +""" +import pytest +from conftest import SITE_URL + + +class TestHomepage: + """Homepage should load with hero, globe animation, and showcase.""" + + def test_homepage_loads(self, page): + response = page.goto(SITE_URL, wait_until="domcontentloaded") + assert response.status == 200 + + def test_homepage_has_title(self, page): + page.goto(SITE_URL, wait_until="domcontentloaded") + assert "iSamples" in page.title() + + def test_homepage_has_hero_text(self, page): + page.goto(SITE_URL, wait_until="domcontentloaded") + assert page.get_by_text("Internet of Samples").count() > 0 + + def test_homepage_has_globe_animation(self, page): + page.goto(SITE_URL, wait_until="domcontentloaded") + globe = page.locator("img[src*='isamples_globe']") + assert globe.count() > 0 + + +class TestAboutPage: + """About page should have all 4 wireframe sections.""" + + def test_about_loads(self, page): + response = page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + assert response.status == 200 + + def test_has_objectives(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + assert page.locator("h2:has-text('Objectives')").count() > 0 + + def test_has_team(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + assert page.locator("h2:has-text('Team')").count() > 0 + + def test_has_photo_gallery(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + assert page.locator("h2:has-text('Photo Gallery')").count() > 0 + + def test_has_background_history(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + assert page.locator("h2:has-text('Background')").count() > 0 + + def test_has_pi_names(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + for name in ["Kerstin Lehnert", "Andrea Thomer", "Neil Davies", "David Vieglais"]: + assert page.get_by_text(name).count() > 0, f"Missing PI: {name}" + + +class TestHowToUsePage: + """How to Use page should have quick start and data tables.""" + + def test_how_to_use_loads(self, page): + response = page.goto(f"{SITE_URL}/how-to-use.html", wait_until="domcontentloaded") + assert response.status == 200 + + def test_has_quick_start(self, page): + page.goto(f"{SITE_URL}/how-to-use.html", wait_until="domcontentloaded") + assert page.get_by_text("Quick Start").count() > 0 + + def test_has_data_sources_table(self, page): + page.goto(f"{SITE_URL}/how-to-use.html", wait_until="domcontentloaded") + assert page.get_by_text("SESAR").count() > 0 + assert page.get_by_text("OpenContext").count() > 0 + + +class TestArchitecturePage: + """Architecture overview should have structured sections.""" + + def test_architecture_loads(self, page): + response = page.goto(f"{SITE_URL}/design/index.html", wait_until="domcontentloaded") + assert response.status == 200 + + def test_has_core_principles(self, page): + page.goto(f"{SITE_URL}/design/index.html", wait_until="domcontentloaded") + assert page.get_by_text("Core Principles").count() > 0 + + def test_has_link_to_requirements(self, page): + page.goto(f"{SITE_URL}/design/index.html", wait_until="domcontentloaded") + assert page.locator("a[href*='requirements']").count() > 0 + + def test_has_link_to_metadata_model(self, page): + page.goto(f"{SITE_URL}/design/index.html", wait_until="domcontentloaded") + assert page.locator("a[href*='metadata']").count() > 0 + + +class TestPublicationsPage: + """Publications page should have presentations and bibliography.""" + + def test_publications_loads(self, page): + response = page.goto(f"{SITE_URL}/pubs.html", wait_until="domcontentloaded") + assert response.status == 200 + + def test_has_presentations_section(self, page): + page.goto(f"{SITE_URL}/pubs.html", wait_until="domcontentloaded") + assert page.get_by_text("Presentations").count() > 0 + + def test_has_spnhc_talk_link(self, page): + page.goto(f"{SITE_URL}/pubs.html", wait_until="domcontentloaded") + assert page.locator("a[href*='youtu']").count() > 0 + + +class TestDataEndpoint: + """data.isamples.org should serve parquet files with range requests.""" + + def test_facet_summaries_accessible(self, page): + # Use Playwright's API request context (not page.goto which triggers download) + response = page.request.head( + "https://data.isamples.org/isamples_202601_facet_summaries.parquet" + ) + assert response.status in (200, 206) + + def test_wide_parquet_supports_range_requests(self, page): + response = page.request.head( + "https://data.isamples.org/isamples_202601_wide.parquet" + ) + assert response.status in (200, 206) + assert "bytes" in response.headers.get("accept-ranges", "") diff --git a/tests/test_navigation.py b/tests/test_navigation.py new file mode 100644 index 0000000..47d36d5 --- /dev/null +++ b/tests/test_navigation.py @@ -0,0 +1,137 @@ +""" +Navigation structure tests — verify sidebar and navbar match the April 2026 wireframe. + +These tests verify the structural changes from PRs #89 (nav restructure) +and #90 (accordion menus). +""" +import pytest +from conftest import SITE_URL + + +class TestSidebarSections: + """Sidebar should show the wireframe's section names.""" + + def test_sidebar_shows_architecture_and_vocabularies(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("Architecture and Vocabularies").count() > 0 + + def test_sidebar_does_not_show_old_information_architecture(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("Information Architecture").count() == 0 + + def test_sidebar_shows_research_and_resources(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("Research & Resources").count() > 0 + + def test_sidebar_does_not_show_separate_published_research(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("Published Research", exact=True).count() == 0 + + def test_sidebar_does_not_show_separate_resources(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + # "Resources" alone shouldn't appear as a section header + # (it's OK inside "Research & Resources") + sections = sidebar.locator(".sidebar-section .sidebar-section-header") + texts = [s.text_content().strip() for s in sections.all()] + assert "Resources" not in texts + + +class TestSidebarHowToUse: + """How to Use section should have 5 sub-items matching wireframe.""" + + def test_how_to_use_has_overview(self, page): + page.goto(f"{SITE_URL}/how-to-use.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("Overview", exact=True).count() > 0 + + def test_how_to_use_has_deep_dive(self, page): + page.goto(f"{SITE_URL}/how-to-use.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("Deep-Dive Analysis").count() > 0 + + def test_how_to_use_has_globe_viz(self, page): + page.goto(f"{SITE_URL}/how-to-use.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("3D Globe Visualization").count() > 0 + + def test_how_to_use_has_search_explorer(self, page): + page.goto(f"{SITE_URL}/how-to-use.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("Search Explorer").count() > 0 + + def test_how_to_use_has_narrow_vs_wide(self, page): + page.goto(f"{SITE_URL}/how-to-use.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("Technical: Narrow vs Wide").count() > 0 + + +class TestSidebarAbout: + """About section should have 4 items matching wireframe.""" + + def test_about_has_objectives(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("Objectives", exact=True).count() > 0 + + def test_about_has_pis_and_contributors(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("PIs and Contributors").count() > 0 + + def test_about_has_photo_gallery(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("Photo Gallery").count() > 0 + + def test_about_has_background_history(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("Background & History").count() > 0 + + +class TestSidebarResearchResources: + """Research & Resources should have 3 items matching wireframe.""" + + def test_has_publications_and_conferences(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("Publications & Conferences").count() > 0 + + def test_has_zenodo_community(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("Zenodo Community").count() > 0 + + def test_has_github_repositories(self, page): + page.goto(f"{SITE_URL}/about.html", wait_until="domcontentloaded") + sidebar = page.locator(".sidebar-navigation") + assert sidebar.get_by_text("Github Repositories").count() > 0 + + +class TestNavbar: + """Top navbar should have the 4 main items.""" + + def test_navbar_has_home(self, page): + page.goto(SITE_URL, wait_until="domcontentloaded") + navbar = page.locator(".navbar") + assert navbar.get_by_text("Home", exact=True).count() > 0 + + def test_navbar_has_interactive_explorer(self, page): + page.goto(SITE_URL, wait_until="domcontentloaded") + navbar = page.locator(".navbar") + assert navbar.get_by_text("Interactive Explorer").count() > 0 + + def test_navbar_has_how_to_use(self, page): + page.goto(SITE_URL, wait_until="domcontentloaded") + navbar = page.locator(".navbar") + assert navbar.get_by_text("How to Use").count() > 0 + + def test_navbar_has_about(self, page): + page.goto(SITE_URL, wait_until="domcontentloaded") + navbar = page.locator(".navbar") + assert navbar.get_by_text("About", exact=True).count() > 0 diff --git a/tests/test_requirements_page.py b/tests/test_requirements_page.py new file mode 100644 index 0000000..ec10d64 --- /dev/null +++ b/tests/test_requirements_page.py @@ -0,0 +1,53 @@ +""" +Requirements page tests — verify accordion behavior from PR #90. + +The Requirements page has 18 collapsible callout sections that should +load collapsed and expand on click. +""" +import pytest +from conftest import SITE_URL + +REQUIREMENTS_URL = f"{SITE_URL}/design/requirements.html" + + +class TestRequirementsAccordions: + """All 18 requirements should be collapsible callouts.""" + + def test_page_loads(self, page): + response = page.goto(REQUIREMENTS_URL, wait_until="domcontentloaded") + assert response.status == 200 + + def test_has_18_callout_sections(self, page): + page.goto(REQUIREMENTS_URL, wait_until="domcontentloaded") + callouts = page.locator(".callout-note") + assert callouts.count() == 18 + + def test_callouts_are_collapsed_by_default(self, page): + page.goto(REQUIREMENTS_URL, wait_until="domcontentloaded") + # Quarto puts "callout-collapse collapse" on the contents div + collapsed = page.locator(".callout-collapse.collapse") + assert collapsed.count() == 18 + + def test_clicking_callout_expands_it(self, page): + page.goto(REQUIREMENTS_URL, wait_until="domcontentloaded") + # Click the first callout header + first_header = page.locator(".callout-note .callout-header").first + first_header.click() + # After clicking, the callout body should be visible + first_body = page.locator(".callout-note .callout-body-container").first + first_body.wait_for(state="visible", timeout=3000) + assert first_body.is_visible() + + def test_first_requirement_is_mint_identifiers(self, page): + page.goto(REQUIREMENTS_URL, wait_until="domcontentloaded") + first_header = page.locator(".callout-note .callout-header").first + assert "Mint Identifiers" in first_header.text_content() + + def test_last_requirement_is_validation(self, page): + page.goto(REQUIREMENTS_URL, wait_until="domcontentloaded") + last_header = page.locator(".callout-note .callout-header").last + assert "Validation" in last_header.text_content() + + def test_has_intro_text(self, page): + page.goto(REQUIREMENTS_URL, wait_until="domcontentloaded") + assert page.get_by_text("Click any requirement to expand").count() > 0