Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: current
- name: Build frontend assets
run: npm ci && npm run build
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Run tests
Expand Down
66 changes: 56 additions & 10 deletions isic/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.test.client import Client
from playwright.sync_api import expect
import pytest
from pytest_factoryboy import register

Expand Down Expand Up @@ -46,7 +47,10 @@ def _disable_direct_pytest_usage():
# also, allow vscode to invoke pytest directly.
# TEST_RUN_PIPE is set by the test runner so it works when using debugpy or not.
if "TOX_ENV_NAME" not in os.environ and not os.environ.get("TEST_RUN_PIPE"):
print("Never invoke pytest directly. Use `uv run tox -e test` instead.", file=sys.stderr) # noqa: T201
print( # noqa: T201
"Never invoke pytest directly. Use `uv run tox -e test` instead.",
file=sys.stderr,
)
sys.exit(1)


Expand Down Expand Up @@ -102,25 +106,67 @@ def staff_client(staff_user):
return client


_PLAYWRIGHT_TIMEOUT = 30_000

expect.set_options(timeout=_PLAYWRIGHT_TIMEOUT)


def _create_context(new_context, live_server):
ctx = new_context(base_url=live_server.url)
ctx.set_default_timeout(_PLAYWRIGHT_TIMEOUT)
return ctx


@pytest.fixture
def authenticated_page(page, live_server, user):
from django.conf import settings as django_settings
def context(new_context, live_server):
return _create_context(new_context, live_server)


def _authenticated_context(new_context, live_server, user):
ctx = _create_context(new_context, live_server)
client = Client()
client.force_login(user)
session_key = client.cookies[django_settings.SESSION_COOKIE_NAME].value

page.goto(live_server.url)
page.context.add_cookies(
session_cookie = client.cookies["sessionid"]
ctx.add_cookies(
[
{
"name": django_settings.SESSION_COOKIE_NAME,
"value": session_key,
"name": "sessionid",
"value": session_cookie.value,
"url": live_server.url,
}
]
)
return page
return ctx


@pytest.fixture
def authenticated_user(user_factory):
return user_factory()


@pytest.fixture
def staff_authenticated_user(user_factory):
return user_factory(is_staff=True)


@pytest.fixture
def authenticated_context(new_context, live_server, authenticated_user):
return _authenticated_context(new_context, live_server, authenticated_user)


@pytest.fixture
def staff_authenticated_context(new_context, live_server, staff_authenticated_user):
return _authenticated_context(new_context, live_server, staff_authenticated_user)


@pytest.fixture
def authenticated_page(authenticated_context):
return authenticated_context.new_page()


@pytest.fixture
def staff_authenticated_page(staff_authenticated_context):
return staff_authenticated_context.new_page()


@pytest.fixture
Expand Down
16 changes: 10 additions & 6 deletions isic/core/templates/core/data_explorer.html
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ <h3 class="font-bold text-lg mb-4">Create Collection</h3>
const THUMBNAIL_BASE_URL = '{{ thumbnail_base_url|escapejs }}';
const IS_AUTHENTICATED = {{ request.user.is_authenticated|yesno:"true,false" }};
const MAX_DISPLAY_ROWS = 1000;
const ISIC_ID_COL = 'isic_id';
const DUCKDB_CDN = 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.32.0';

function getQueryFromUrl() {
const params = new URLSearchParams(window.location.search);
Expand Down Expand Up @@ -348,7 +350,7 @@ <h3 class="font-bold text-lg mb-4">Create Collection</h3>
},

get hasIsicId() {
return this.columns.includes('isic_id');
return this.columns.includes(ISIC_ID_COL);
},

get canCreateCollection() {
Expand Down Expand Up @@ -419,8 +421,8 @@ <h3 class="font-bold text-lg mb-4">Create Collection</h3>
this.status = 'Initializing DuckDB...';
const DUCKDB_CONFIG = await duckdb.selectBundle({
eh: {
mainModule: 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.32.0/dist/duckdb-eh.wasm',
mainWorker: 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.32.0/dist/duckdb-browser-eh.worker.js',
mainModule: DUCKDB_CDN + '/dist/duckdb-eh.wasm',
mainWorker: DUCKDB_CDN + '/dist/duckdb-browser-eh.worker.js',
},
});
const logger = new duckdb.ConsoleLogger();
Expand Down Expand Up @@ -452,7 +454,9 @@ <h3 class="font-bold text-lg mb-4">Create Collection</h3>
}

this.ready = true;
if (getQueryFromUrl()) this.runQuery();
if (getQueryFromUrl()) {
this.runQuery();
}
} catch (e) {
this.error = 'Failed to initialize: ' + e.message;
}
Expand Down Expand Up @@ -537,7 +541,7 @@ <h3 class="font-bold text-lg mb-4">Create Collection</h3>

try {
if (!this.showingSubset) {
const ids = new Set(this.rows.map(r => r.isic_id).filter(Boolean));
const ids = new Set(this.rows.map(r => r[ISIC_ID_COL]).filter(Boolean));
this.collectionImageCount = ids.size;
} else {
const countResult = await this.conn.query(
Expand Down Expand Up @@ -588,7 +592,7 @@ <h3 class="font-bold text-lg mb-4">Create Collection</h3>
const idsResult = await this.conn.query(
`SELECT DISTINCT isic_id FROM (${this.lastQueryText}) sub WHERE isic_id IS NOT NULL`
);
const isicIds = idsResult.toArray().map(r => r.isic_id);
const isicIds = idsResult.toArray().map(r => r[ISIC_ID_COL]);

if (isicIds.length === 0) {
this.createCollectionError = 'No images found in query results.';
Expand Down
5 changes: 5 additions & 0 deletions isic/core/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ def inner():
return inner


@pytest.fixture
def _mock_datacite_create_draft_doi(mocker):
mocker.patch("isic.core.services.collection.doi._datacite_create_draft_doi")


@pytest.fixture
def mock_fetch_doi_schema_org_dataset(mocker):
return mocker.patch(
Expand Down
155 changes: 155 additions & 0 deletions isic/core/tests/image_browser/test_browser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
from django.urls import reverse
from playwright.sync_api import expect
import pytest

from isic.core.services.collection.image import collection_add_images


@pytest.mark.playwright
def test_collection_picker_dropdown_filter_select_and_keyboard(
page, collection_factory, image_factory
):
collections = [collection_factory(public=True, pinned=True) for _ in range(3)]
# The picker sorts alphabetically by name
collections.sort(key=lambda c: c.name)

# Create images and assign them to specific collections so we can verify
# that filtering by collection shows only the correct images.
img_in_second = image_factory(public=True)
collection_add_images(collection=collections[1], image=img_in_second)
img_in_third = image_factory(public=True)
collection_add_images(collection=collections[2], image=img_in_third)
img_unaffiliated = image_factory(public=True)

page.goto(reverse("core/image-browser"))

picker_input = page.get_by_placeholder("Type or select collections...")

# Clicking the input opens the dropdown showing all 3 collections
picker_input.click()
for c in collections:
expect(page.get_by_text(c.name, exact=True)).to_be_visible()

# Type the first few characters of a collection name to filter
first = collections[0]
prefix = first.name[:5]
picker_input.fill(prefix)
expect(page.get_by_text(first.name, exact=True)).to_be_visible()

# Click the first collection to select it -- a chip should appear
page.get_by_text(first.name, exact=True).click()
expect(page.get_by_text(first.name, exact=True)).to_be_visible()

# The selected collection is excluded from the dropdown. Reopen and clear to show
# only the 2 unselected collections.
picker_input.click()
picker_input.fill("")
for c in collections[1:]:
expect(page.get_by_text(c.name, exact=True)).to_be_visible()

# Close dropdown so we can interact with the chip area below it
page.keyboard.press("Escape")

# Remove the chip by clicking its X button
page.get_by_text(first.name, exact=True).locator("..").get_by_role("button").click()

# Reopen the dropdown -- all 3 collections should appear since nothing is selected
picker_input.click()
picker_input.fill("")
for c in collections:
expect(page.get_by_text(c.name, exact=True)).to_be_visible()
page.keyboard.press("ArrowDown")
page.keyboard.press("ArrowDown")
page.keyboard.press("Enter")
second = collections[1]
expect(page.get_by_text(second.name, exact=True)).to_be_visible()

# ArrowDown past the last item clamps to the end -- pressing Enter selects it
third = collections[2]
picker_input.fill("")
for _ in range(10):
page.keyboard.press("ArrowDown")
page.keyboard.press("Enter")
expect(page.get_by_text(third.name, exact=True)).to_be_visible()

# Escape closes the dropdown -- the non-selected collection should no longer be visible
picker_input.click()
expect(page.get_by_text(first.name, exact=True)).to_be_visible()
page.keyboard.press("Escape")
expect(page.get_by_text(first.name, exact=True)).not_to_be_visible()

# Submit the form and verify only images from the selected collections are shown.
# second and third are selected; first was removed.
page.get_by_role("button", name="Search").click()
page.wait_for_load_state("networkidle")

page_text = page.text_content("body")
assert img_in_second.isic_id in page_text
assert img_in_third.isic_id in page_text
assert img_unaffiliated.isic_id not in page_text

# The chips survive the round-trip (picker re-initializes from URL params).
# Each chip contains the name and a close button, distinguishing it from dropdown items.
second_chip = (
page.locator("span").filter(has_text=second.name).filter(has=page.get_by_role("button"))
)
third_chip = (
page.locator("span").filter(has_text=third.name).filter(has=page.get_by_role("button"))
)
expect(second_chip).to_be_visible()
expect(third_chip).to_be_visible()


@pytest.mark.playwright
def test_add_to_collection_modal_with_recent_search_and_confirm(
authenticated_page, authenticated_user, collection_factory, image_factory
):
page = authenticated_page
user = authenticated_user

user_collection = collection_factory(creator=user, public=False, locked=False)
image_factory(public=True)

page.goto(reverse("core/image-browser"))

# Open the Actions dropdown and click "Add results to collection"
page.get_by_role("button", name="Actions").click()
page.get_by_role("menuitem", name="Add results to collection").click()

# The modal appears showing the user's recent collection
modal_heading = page.get_by_role("heading", name="Add to Collection")
expect(modal_heading).to_be_visible()
expect(page.get_by_text(user_collection.name, exact=True).first).to_be_visible()

# Search for the collection by typing part of its name (debounced 300ms AJAX)
search_input = page.get_by_placeholder("Type to search...")
search_query = user_collection.name[:6]
search_input.fill(search_query)
expect(page.get_by_role("button", name=user_collection.name)).to_be_visible()

# Use a mutable action so we can switch between dismiss and accept
dialog_action = ["dismiss"]

def handle_dialog(dialog):
if dialog_action[0] == "dismiss":
dialog.dismiss()
else:
dialog.accept()

page.on("dialog", handle_dialog)

# Click the search result -- confirm dialog appears and is dismissed
page.get_by_role("button", name=user_collection.name).click()
# The page stays on the image browser and the modal remains open
expect(modal_heading).to_be_visible()

# Clear the search to go back to the recent collections view, then accept the dialog
search_input.fill("")
dialog_action[0] = "accept"
expect(page.get_by_text("or choose from recent")).to_be_visible()
page.get_by_text("or choose from recent").locator("..").get_by_role(
"button", name=user_collection.name
).click()

# After accepting, the API call triggers and the page redirects to the collection
page.wait_for_url(f"**{reverse('core/collection-detail', args=[user_collection.pk])}")
Loading