diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 92eeb63e6..66eabbbf7 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -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
diff --git a/isic/conftest.py b/isic/conftest.py
index f32125e5a..7e76479e0 100644
--- a/isic/conftest.py
+++ b/isic/conftest.py
@@ -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
@@ -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)
@@ -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
diff --git a/isic/core/templates/core/data_explorer.html b/isic/core/templates/core/data_explorer.html
index b9a1d3b1c..a43b9cd92 100644
--- a/isic/core/templates/core/data_explorer.html
+++ b/isic/core/templates/core/data_explorer.html
@@ -183,6 +183,8 @@
Create Collection
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);
@@ -348,7 +350,7 @@ Create Collection
},
get hasIsicId() {
- return this.columns.includes('isic_id');
+ return this.columns.includes(ISIC_ID_COL);
},
get canCreateCollection() {
@@ -419,8 +421,8 @@ Create Collection
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();
@@ -452,7 +454,9 @@ Create Collection
}
this.ready = true;
- if (getQueryFromUrl()) this.runQuery();
+ if (getQueryFromUrl()) {
+ this.runQuery();
+ }
} catch (e) {
this.error = 'Failed to initialize: ' + e.message;
}
@@ -537,7 +541,7 @@ Create Collection
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(
@@ -588,7 +592,7 @@ Create Collection
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.';
diff --git a/isic/core/tests/conftest.py b/isic/core/tests/conftest.py
index 5a65bcf92..41d134ebd 100644
--- a/isic/core/tests/conftest.py
+++ b/isic/core/tests/conftest.py
@@ -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(
diff --git a/isic/core/tests/image_browser/test_browser.py b/isic/core/tests/image_browser/test_browser.py
new file mode 100644
index 000000000..daddf96df
--- /dev/null
+++ b/isic/core/tests/image_browser/test_browser.py
@@ -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])}")
diff --git a/isic/core/tests/test_collection_detail_browser.py b/isic/core/tests/test_collection_detail_browser.py
new file mode 100644
index 000000000..7fef85107
--- /dev/null
+++ b/isic/core/tests/test_collection_detail_browser.py
@@ -0,0 +1,138 @@
+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_detail_lazy_attribution_and_share_modal(
+ staff_authenticated_page,
+ staff_authenticated_user,
+ collection_factory,
+ image_factory,
+ user_factory,
+):
+ page = staff_authenticated_page
+ staff_user = staff_authenticated_user
+
+ collection = collection_factory(public=False, locked=False, creator=staff_user)
+ images = [image_factory(public=True) for _ in range(3)]
+ for img in images:
+ collection_add_images(collection=collection, image=img)
+
+ share_target = user_factory()
+
+ page.goto(reverse("core/collection-detail", args=[collection.pk]))
+
+ # -- Lazy attribution loading --
+ attribution_section = page.get_by_text("Attribution").locator("..")
+ attribution_link = attribution_section.get_by_role("link", name="View")
+ expect(attribution_link).to_be_visible()
+
+ attribution_link.click()
+
+ # After clicking, attribution data should load and display
+ attribution_list = attribution_section.locator("ul li")
+ expect(attribution_list.first).to_be_visible()
+
+ # The "View" link should be hidden after fetching
+ expect(attribution_link).not_to_be_visible()
+
+ # -- Share modal --
+ page.get_by_role("button", name="Actions").click()
+ page.get_by_role("menuitem", name="Share").click()
+
+ modal = page.get_by_role("dialog")
+ expect(modal).to_be_visible()
+
+ # Select2 should be initialized - type in the search to find the target user.
+ # Use press_sequentially instead of fill because Select2 listens for keyboard
+ # events to trigger AJAX searches, and fill() dispatches a single input event
+ # that Select2 doesn't reliably handle.
+ page.locator(".select2-container").click()
+ select2_input = page.locator(".select2-search__field")
+ expect(select2_input).to_be_visible()
+ select2_input.press_sequentially(share_target.email)
+
+ # Wait for the AJAX results to appear and select the user
+ select2_result = page.locator(".select2-results__option", has_text=share_target.email)
+ expect(select2_result).to_be_visible()
+ select2_result.click()
+
+ # The user should appear in the Select2 selection
+ expect(page.locator(".select2-selection__choice", has_text=share_target.email)).to_be_visible()
+
+ # Uncheck email notifications to avoid slow email sending during teardown
+ page.get_by_label("Send email notification to selected users").uncheck()
+
+ # Handle the confirm dialog and share
+ page.on("dialog", lambda dialog: dialog.accept())
+ modal.get_by_role("button", name="Share collection and images with users").click()
+
+ # Page reloads after sharing - verify the shared user appears
+ expect(page.get_by_text("Directly shared with")).to_be_visible()
+ expect(page.get_by_text(share_target.email)).to_be_visible()
+
+ # The share triggers a page reload via window.location.reload(). The WSGI handler's
+ # post-response cleanup (close_old_connections) runs in the server thread and can race
+ # with test teardown's database flush. Wait briefly to let the server finish.
+ page.wait_for_timeout(1000)
+
+
+@pytest.mark.playwright
+def test_collection_detail_image_removal(
+ authenticated_page, authenticated_user, collection_factory, image_factory
+):
+ page = authenticated_page
+ user = authenticated_user
+
+ collection = collection_factory(public=True, locked=False, creator=user)
+ images = [image_factory(public=True) for _ in range(3)]
+ for img in images:
+ collection_add_images(collection=collection, image=img)
+
+ page.goto(reverse("core/collection-detail", args=[collection.pk]))
+
+ # Enter image removal mode via the Actions menu
+ page.get_by_role("button", name="Actions").click()
+ page.get_by_role("menuitem", name="Remove Images").click()
+
+ # The removal mode alert should be visible
+ expect(page.get_by_text("images pending removal")).to_be_visible()
+
+ # Toggle the first two images for removal
+ first_image = images[0]
+ second_image = images[1]
+
+ page.get_by_text(first_image.isic_id).locator("..").get_by_role("button", name="Remove").click()
+ page.get_by_text(second_image.isic_id).locator("..").get_by_role(
+ "button", name="Remove"
+ ).click()
+
+ # The pending count should now show 2
+ expect(page.get_by_text("2 images pending removal")).to_be_visible()
+
+ # Un-toggle the second image
+ page.get_by_text(second_image.isic_id).locator("..").get_by_role(
+ "button", name="Remove"
+ ).click()
+
+ # Handle the confirm dialog and click the bulk "Remove" button in the alert bar.
+ # Use the Abort button as anchor since it's unique on the page and sits next to Remove.
+ page.on("dialog", lambda dialog: dialog.accept())
+ page.get_by_role("button", name="Abort").locator("..").get_by_role(
+ "button", name="Remove"
+ ).click()
+
+ # Page redirects to the collection detail (without removal mode)
+ page.wait_for_url(f"**{reverse('core/collection-detail', args=[collection.pk])}")
+
+ # Only the first image should have been removed
+ page_text = page.text_content("body")
+ assert first_image.isic_id not in page_text
+ assert second_image.isic_id in page_text
+ assert images[2].isic_id in page_text
+
+ # Verify in the database
+ assert collection.images.count() == 2
diff --git a/isic/core/tests/test_collection_share_browser.py b/isic/core/tests/test_collection_share_browser.py
new file mode 100644
index 000000000..9a5f88c14
--- /dev/null
+++ b/isic/core/tests/test_collection_share_browser.py
@@ -0,0 +1,80 @@
+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_share_collection_with_multiple_users(
+ staff_authenticated_page,
+ staff_authenticated_user,
+ collection_factory,
+ image_factory,
+ user_factory,
+):
+ page = staff_authenticated_page
+ staff_user = staff_authenticated_user
+
+ collection = collection_factory(public=False, locked=False, creator=staff_user)
+ image = image_factory(public=True)
+ collection_add_images(collection=collection, image=image)
+
+ target_a = user_factory()
+ target_b = user_factory()
+
+ page.goto(reverse("core/collection-detail", args=[collection.pk]))
+
+ # Verify nobody is shared with initially
+ expect(page.get_by_text("Directly shared with")).to_be_visible()
+ expect(page.get_by_text("nobody")).to_be_visible()
+
+ # Open the share modal
+ page.get_by_role("button", name="Actions").click()
+ page.get_by_role("menuitem", name="Share").click()
+
+ modal = page.get_by_role("dialog")
+ expect(modal).to_be_visible()
+
+ # Search for and select the first user.
+ # Use press_sequentially instead of fill because Select2 listens for keyboard
+ # events to trigger AJAX searches, and fill() dispatches a single input event
+ # that Select2 doesn't reliably handle.
+ page.locator(".select2-container").click()
+ select2_input = page.locator(".select2-search__field")
+ expect(select2_input).to_be_visible()
+ select2_input.press_sequentially(target_a.email)
+ result_a = page.locator(".select2-results__option", has_text=target_a.email)
+ expect(result_a).to_be_visible()
+ result_a.click()
+
+ # Search for and select the second user
+ page.locator(".select2-container").click()
+ select2_input = page.locator(".select2-search__field")
+ expect(select2_input).to_be_visible()
+ select2_input.press_sequentially(target_b.email)
+ result_b = page.locator(".select2-results__option", has_text=target_b.email)
+ expect(result_b).to_be_visible()
+ result_b.click()
+
+ # Both users should appear as Select2 choices
+ expect(page.locator(".select2-selection__choice", has_text=target_a.email)).to_be_visible()
+ expect(page.locator(".select2-selection__choice", has_text=target_b.email)).to_be_visible()
+
+ # Uncheck email notifications to avoid slow email sending during teardown
+ page.get_by_label("Send email notification to selected users").uncheck()
+
+ # Accept the confirm dialog and share
+ page.on("dialog", lambda dialog: dialog.accept())
+ modal.get_by_role("button", name="Share collection and images with users").click()
+
+ # Page reloads - verify the flash message and both shared users appear
+ expect(page.get_by_text("Sharing collection with user(s)")).to_be_visible()
+ expect(page.get_by_text("Directly shared with")).to_be_visible()
+ expect(page.get_by_text(target_a.email)).to_be_visible()
+ expect(page.get_by_text(target_b.email)).to_be_visible()
+
+ # The "nobody" text should no longer appear
+ expect(page.get_by_text("nobody")).not_to_be_visible()
+
+ page.wait_for_timeout(1000)
diff --git a/isic/core/tests/test_data_explorer_browser.py b/isic/core/tests/test_data_explorer_browser.py
index 777e7e78e..b2d019272 100644
--- a/isic/core/tests/test_data_explorer_browser.py
+++ b/isic/core/tests/test_data_explorer_browser.py
@@ -48,11 +48,12 @@ def data_explorer_parquet(settings):
original_base_url = storage.base_url
storage.base_url = f"{storage.endpoint_url}/{storage.bucket_name}"
- yield
-
- storage.base_url = original_base_url
- storage.delete(key)
- parquet_path.unlink(missing_ok=True)
+ try:
+ yield
+ finally:
+ storage.base_url = original_base_url
+ storage.delete(key)
+ parquet_path.unlink(missing_ok=True)
def _wait_for_ready(page):
@@ -75,9 +76,8 @@ def _run_query(page, query):
@pytest.mark.playwright
-@pytest.mark.django_db(transaction=True)
-def test_data_explorer_loads_and_runs_query(page, live_server, data_explorer_parquet):
- page.goto(f"{live_server.url}{reverse('core/data-explorer')}", timeout=30_000)
+def test_data_explorer_loads_and_runs_query(page, data_explorer_parquet):
+ page.goto(reverse("core/data-explorer"), timeout=30_000)
_wait_for_ready(page)
expect(page.locator("#data-explorer-main")).to_contain_text("10 images")
@@ -95,9 +95,8 @@ def test_data_explorer_loads_and_runs_query(page, live_server, data_explorer_par
@pytest.mark.playwright
-@pytest.mark.django_db(transaction=True)
-def test_data_explorer_shows_error_for_bad_query(page, live_server, data_explorer_parquet):
- page.goto(f"{live_server.url}{reverse('core/data-explorer')}", timeout=30_000)
+def test_data_explorer_shows_error_for_bad_query(page, data_explorer_parquet):
+ page.goto(reverse("core/data-explorer"), timeout=30_000)
_wait_for_ready(page)
_run_query(page, "SELECT * FROM nonexistent_table")
@@ -106,9 +105,8 @@ def test_data_explorer_shows_error_for_bad_query(page, live_server, data_explore
@pytest.mark.playwright
-@pytest.mark.django_db(transaction=True)
-def test_data_explorer_example_query(page, live_server, data_explorer_parquet):
- page.goto(f"{live_server.url}{reverse('core/data-explorer')}", timeout=30_000)
+def test_data_explorer_example_query(page, data_explorer_parquet):
+ page.goto(reverse("core/data-explorer"), timeout=30_000)
_wait_for_ready(page)
_run_query(
@@ -124,11 +122,10 @@ def test_data_explorer_example_query(page, live_server, data_explorer_parquet):
@pytest.mark.playwright
-@pytest.mark.django_db(transaction=True)
-def test_data_explorer_query_sharing_via_link(page, live_server, data_explorer_parquet):
+def test_data_explorer_query_sharing_via_link(page, data_explorer_parquet):
query = "SELECT sex, COUNT(*) AS count FROM metadata GROUP BY sex ORDER BY count DESC"
page.goto(
- f"{live_server.url}{reverse('core/data-explorer')}?q={quote(query)}",
+ f"{reverse('core/data-explorer')}?q={quote(query)}",
timeout=30_000,
)
_wait_for_ready(page)
@@ -139,20 +136,18 @@ def test_data_explorer_query_sharing_via_link(page, live_server, data_explorer_p
@pytest.mark.playwright
-@pytest.mark.django_db(transaction=True)
-def test_data_explorer_no_parquet_shows_error(page, live_server):
- page.goto(f"{live_server.url}{reverse('core/data-explorer')}")
+def test_data_explorer_no_parquet_shows_error(page):
+ page.goto(reverse("core/data-explorer"))
expect(page.locator("body")).to_contain_text("Failed to initialize", timeout=30_000)
@pytest.mark.playwright
-@pytest.mark.django_db(transaction=True)
def test_data_explorer_create_collection_focuses_name_input(
- authenticated_page, live_server, data_explorer_parquet
+ authenticated_page, data_explorer_parquet
):
page = authenticated_page
- page.goto(f"{live_server.url}{reverse('core/data-explorer')}", timeout=30_000)
+ page.goto(reverse("core/data-explorer"), timeout=30_000)
_wait_for_ready(page)
_run_query(page, "SELECT isic_id FROM metadata")
diff --git a/isic/core/tests/test_doi_browser.py b/isic/core/tests/test_doi_browser.py
new file mode 100644
index 000000000..11ee8d58c
--- /dev/null
+++ b/isic/core/tests/test_doi_browser.py
@@ -0,0 +1,89 @@
+from django.template.defaultfilters import slugify
+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
+@pytest.mark.usefixtures(
+ "_mock_datacite_create_draft_doi",
+ "mock_fetch_doi_citations",
+ "mock_fetch_doi_schema_org_dataset",
+)
+def test_doi_creation_form_related_identifiers_and_submit(
+ staff_authenticated_page, collection_factory, image_factory
+):
+ page = staff_authenticated_page
+
+ collection = collection_factory(public=True, locked=False)
+ for _ in range(3):
+ collection_add_images(collection=collection, image=image_factory(public=True))
+
+ page.goto(reverse("core/collection-create-doi", args=[collection.pk]))
+
+ # Description should be pre-populated from the collection
+ expect(page.get_by_label("Description")).to_have_value(collection.description)
+
+ # -- IsDescribedBy: unique constraint --
+ add_descriptor_btn = page.get_by_role("button", name="Add Descriptor")
+ described_section = add_descriptor_btn.locator("xpath=../..")
+ expect(add_descriptor_btn).to_be_enabled()
+
+ add_descriptor_btn.click()
+
+ # After adding one, the button should be disabled (unique constraint)
+ expect(add_descriptor_btn).to_be_disabled()
+
+ # Fill in the descriptor: select DOI type and enter a DOI value
+ descriptor_doi = f"10.1000/descriptor-{collection.pk}"
+ described_section.get_by_role("combobox").select_option("DOI")
+ described_section.get_by_role("textbox").fill(descriptor_doi)
+
+ # -- IsReferencedBy: add, fill, and remove --
+ add_reference_btn = page.get_by_role("button", name="Add Reference")
+ referenced_section = add_reference_btn.locator("xpath=../..")
+ add_reference_btn.click()
+
+ # Fill in a URL type reference
+ reference_url = f"https://example.com/reference-{collection.pk}"
+ referenced_section.get_by_role("combobox").first.select_option("URL")
+ referenced_section.get_by_role("textbox").first.fill(reference_url)
+
+ # Add a second reference
+ reference_doi = f"10.1000/ref-{collection.pk}"
+ add_reference_btn.click()
+ referenced_section.get_by_role("combobox").nth(1).select_option("DOI")
+ referenced_section.get_by_role("textbox").nth(1).fill(reference_doi)
+
+ # Remove the first reference (the URL one)
+ referenced_section.get_by_role("button", name="\u00d7").first.click()
+
+ # Only the DOI reference should remain
+ expect(referenced_section.get_by_role("combobox")).to_have_count(1)
+ expect(referenced_section.get_by_role("textbox")).to_have_count(1)
+
+ # -- IsSupplementedBy: add one --
+ add_supplement_btn = page.get_by_role("button", name="Add Supplement")
+ supplemented_section = add_supplement_btn.locator("xpath=../..")
+ add_supplement_btn.click()
+ supplement_url = f"https://github.com/example/repo-{collection.pk}"
+ supplemented_section.get_by_role("combobox").select_option("URL")
+ supplemented_section.get_by_role("textbox").fill(supplement_url)
+
+ # Submit the form
+ page.get_by_role("button", name="Create Draft DOI").click()
+
+ # Should redirect to the DOI detail page
+ expected_slug = slugify(collection.name)
+ page.wait_for_url(f"**{reverse('core/doi-detail', kwargs={'slug': expected_slug})}")
+
+ # Verify DOI detail page content
+ expect(page.get_by_role("heading", name="Draft DOI")).to_be_visible()
+ expect(page.get_by_text(collection.name).first).to_be_visible()
+
+ # Verify the identifiers appear on the detail page
+ expect(page.get_by_text(descriptor_doi)).to_be_visible()
+ expect(page.get_by_text(reference_doi)).to_be_visible()
+ expect(page.get_by_text(supplement_url)).to_be_visible()
diff --git a/isic/core/tests/test_flash_messages_browser.py b/isic/core/tests/test_flash_messages_browser.py
new file mode 100644
index 000000000..45425ed1c
--- /dev/null
+++ b/isic/core/tests/test_flash_messages_browser.py
@@ -0,0 +1,90 @@
+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_add_images_to_collection_shows_flash_message(
+ 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 "Add results to collection" modal
+ page.get_by_role("button", name="Actions").click()
+ page.get_by_role("menuitem", name="Add results to collection").click()
+
+ # Accept the confirm dialog when it appears
+ page.on("dialog", lambda dialog: dialog.accept())
+
+ # Select the collection from the recent section
+ 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()
+
+ # Should redirect to collection detail with the flash message
+ page.wait_for_url(f"**{reverse('core/collection-detail', args=[user_collection.pk])}")
+ expect(page.get_by_text("Adding images to collection")).to_be_visible()
+
+
+@pytest.mark.playwright
+def test_image_removal_shows_flash_message(
+ authenticated_page, authenticated_user, collection_factory, image_factory
+):
+ page = authenticated_page
+ user = authenticated_user
+
+ collection = collection_factory(public=True, locked=False, creator=user)
+ images = [image_factory(public=True) for _ in range(2)]
+ for img in images:
+ collection_add_images(collection=collection, image=img)
+
+ page.goto(reverse("core/collection-detail", args=[collection.pk]))
+
+ # Enter removal mode, toggle one image, and remove it
+ page.get_by_role("button", name="Actions").click()
+ page.get_by_role("menuitem", name="Remove Images").click()
+
+ page.get_by_text(images[0].isic_id).locator("..").get_by_role("button", name="Remove").click()
+
+ page.on("dialog", lambda dialog: dialog.accept())
+ page.get_by_role("button", name="Abort").locator("..").get_by_role(
+ "button", name="Remove"
+ ).click()
+
+ # Should redirect to collection detail with a flash message about removal
+ page.wait_for_url(f"**{reverse('core/collection-detail', args=[collection.pk])}")
+ expect(page.get_by_text("Removed 1 images")).to_be_visible()
+
+
+@pytest.mark.playwright
+def test_study_creation_shows_flash_message(
+ authenticated_page, authenticated_user, collection_factory, image_factory
+):
+ page = authenticated_page
+ user = authenticated_user
+
+ collection = collection_factory(creator=user, locked=False)
+ collection_add_images(collection=collection, image=image_factory(public=True))
+
+ page.goto(reverse("study-create"))
+
+ page.get_by_label("Name").fill(f"Study {collection.name}")
+ page.get_by_label("Description").fill(f"Description for {collection.name}")
+ page.get_by_label("Attribution").fill(f"Attribution {collection.name}")
+ page.get_by_label("Collection").select_option(str(collection.pk))
+ page.get_by_label("Annotators").fill(user.email)
+
+ page.get_by_role("button", name="Create Study").click()
+
+ # Should redirect to study detail with a flash message
+ page.wait_for_url("**/studies/*/")
+ expect(page.get_by_text("Creating study, this may take a few minutes.")).to_be_visible()
diff --git a/isic/find/tests/test_quickfind_browser.py b/isic/find/tests/test_quickfind_browser.py
index 67142eda7..a8db605cf 100644
--- a/isic/find/tests/test_quickfind_browser.py
+++ b/isic/find/tests/test_quickfind_browser.py
@@ -3,9 +3,8 @@
@pytest.mark.playwright
-@pytest.mark.django_db(transaction=True)
-def test_quickfind_focuses_search_input(page, live_server):
- page.goto(live_server.url)
+def test_quickfind_focuses_search_input(page):
+ page.goto("/")
quickfind_input = page.locator("#quickfind-input")
page.locator("#quickfind-button").click()
@@ -23,8 +22,7 @@ def test_quickfind_focuses_search_input(page, live_server):
@pytest.mark.playwright
-@pytest.mark.django_db(transaction=True)
-def test_quickfind_focus_not_racing_xshow(page, live_server):
+def test_quickfind_focus_not_racing_xshow(page):
"""
Regression: repeated open/close could leave quickfind unfocused.
@@ -34,7 +32,7 @@ def test_quickfind_focus_not_racing_xshow(page, live_server):
with it and could call focus() on a still-hidden element (a silent no-op).
This verifies that focus() fires only after the modal is visible.
"""
- page.goto(live_server.url)
+ page.goto("/")
was_visible_on_focus = page.evaluate("""async () => {
const input = document.getElementById('quickfind-input');
diff --git a/isic/ingest/templates/ingest/partials/review_footer.html b/isic/ingest/templates/ingest/partials/review_footer.html
index 5b3a2a26f..d1d20dff1 100644
--- a/isic/ingest/templates/ingest/partials/review_footer.html
+++ b/isic/ingest/templates/ingest/partials/review_footer.html
@@ -37,7 +37,7 @@
});
try {
- const { data } = await axiosSession.post('{% url "api:create_review_bulk" %}',
+ const { data } = await axiosSession.post('{% url "api:accession_review_bulk_create" %}',
accessionReviews);
this.submitted = true;
this.submitting = false;
diff --git a/isic/ingest/tests/test_merge_cohorts_browser.py b/isic/ingest/tests/test_merge_cohorts_browser.py
new file mode 100644
index 000000000..5143b3148
--- /dev/null
+++ b/isic/ingest/tests/test_merge_cohorts_browser.py
@@ -0,0 +1,69 @@
+from django.urls import reverse
+from playwright.sync_api import expect
+import pytest
+
+from isic.core.services.collection.image import collection_add_images
+from isic.ingest.models import Cohort
+
+
+@pytest.mark.playwright
+def test_merge_cohorts_autocomplete_preview_and_submit(
+ staff_authenticated_page,
+ cohort_factory,
+ accession_factory,
+ image_factory,
+ collection_factory,
+):
+ page = staff_authenticated_page
+
+ # Create two cohorts with accessions
+ collection_a = collection_factory()
+ cohort_a = cohort_factory(collection=collection_a)
+ accession_a = accession_factory(cohort=cohort_a)
+ image_a = image_factory(accession=accession_a, public=True)
+ collection_add_images(collection=collection_a, image=image_a)
+
+ collection_b = collection_factory()
+ cohort_b = cohort_factory(collection=collection_b)
+ accession_b = accession_factory(cohort=cohort_b)
+ image_b = image_factory(accession=accession_b, public=True)
+ collection_add_images(collection=collection_b, image=image_b)
+
+ page.goto(reverse("merge-cohorts"))
+
+ expect(page.get_by_text("Merge Cohorts").first).to_be_visible()
+
+ # Type in the first autocomplete field to search for cohort_a
+ first_input = page.locator("input[name='autocomplete_cohort']")
+ first_input.press_sequentially(cohort_a.name[:5], delay=50)
+
+ # Wait for autocomplete results and select cohort_a
+ first_result = page.get_by_text(cohort_a.name, exact=True).first
+ expect(first_result).to_be_visible()
+ first_result.click()
+
+ # Preview should show cohort details
+ expect(page.get_by_text(cohort_a.description).first).to_be_visible()
+
+ # Type in the second autocomplete field to search for cohort_b
+ second_input = page.locator("input[name='autocomplete_cohort_to_merge']")
+ second_input.press_sequentially(cohort_b.name[:5], delay=50)
+
+ # Wait for autocomplete results and select cohort_b
+ second_result = page.get_by_text(cohort_b.name, exact=True).first
+ expect(second_result).to_be_visible()
+ second_result.click()
+
+ # Preview should show cohort_b details
+ expect(page.get_by_text(cohort_b.description).first).to_be_visible()
+
+ # Submit the merge
+ page.get_by_role("button", name="Merge Cohorts").click()
+
+ # Should redirect to cohort_a detail page with success flash message
+ page.wait_for_url(f"**{reverse('cohort-detail', args=[cohort_a.pk])}")
+ expect(page.get_by_text("Cohort merged successfully.")).to_be_visible()
+
+ # Verify cohort_b was deleted and its accessions moved to cohort_a
+ assert not Cohort.objects.filter(pk=cohort_b.pk).exists()
+ assert cohort_a.accessions.count() == 2
diff --git a/isic/ingest/tests/test_publish_browser.py b/isic/ingest/tests/test_publish_browser.py
new file mode 100644
index 000000000..6677da0b6
--- /dev/null
+++ b/isic/ingest/tests/test_publish_browser.py
@@ -0,0 +1,96 @@
+from django.urls import reverse
+from playwright.sync_api import expect
+import pytest
+
+from isic.ingest.models import AccessionStatus
+
+
+@pytest.fixture
+def publishable_cohort(user_factory, cohort_factory, accession_review_factory):
+ staff_user = user_factory(is_staff=True)
+ cohort = cohort_factory(creator=staff_user, contributor__creator=staff_user)
+ accession_review_factory(
+ accession__cohort=cohort,
+ accession__status=AccessionStatus.SUCCEEDED,
+ accession__blob_size=1,
+ accession__width=1,
+ accession__height=1,
+ creator=staff_user,
+ value=True,
+ )
+ return cohort
+
+
+@pytest.mark.playwright
+def test_cohort_publish(staff_authenticated_page, publishable_cohort):
+ page = staff_authenticated_page
+ cohort = publishable_cohort
+
+ page.goto(reverse("upload/cohort-publish", args=[cohort.pk]))
+
+ expect(page.get_by_text("There are 1 accessions that can be published")).to_be_visible()
+
+ page.get_by_label("Make images public").check()
+
+ page.on("dialog", lambda dialog: dialog.accept())
+ page.get_by_role("button", name="Publish 1 accessions").click()
+
+ page.wait_for_url(f"**{reverse('cohort-detail', args=[cohort.pk])}")
+ expect(page.get_by_text("Publishing 1 image")).to_be_visible()
+
+
+@pytest.mark.playwright
+def test_cohort_publish_with_additional_collections(
+ staff_authenticated_page,
+ publishable_cohort,
+ collection_factory,
+ user_factory,
+):
+ page = staff_authenticated_page
+ cohort = publishable_cohort
+
+ other_user = user_factory()
+ extra_collection_a = collection_factory(creator=other_user, public=False, locked=False)
+ extra_collection_b = collection_factory(creator=other_user, public=False, locked=False)
+
+ page.goto(reverse("upload/cohort-publish", args=[cohort.pk]))
+
+ expect(page.get_by_text("There are 1 accessions that can be published")).to_be_visible()
+
+ # Select the first additional collection via Select2.
+ # Use press_sequentially instead of fill because Select2 listens for keyboard
+ # events to trigger AJAX searches, and fill() dispatches a single input event
+ # that Select2 doesn't reliably handle.
+ page.locator(".select2-container").click()
+ select2_input = page.locator(".select2-search__field")
+ expect(select2_input).to_be_visible()
+ select2_input.press_sequentially(extra_collection_a.name[:5])
+
+ result_a = page.locator(".select2-results__option", has_text=extra_collection_a.name)
+ expect(result_a).to_be_visible()
+ result_a.click()
+
+ expect(
+ page.locator(".select2-selection__choice", has_text=extra_collection_a.name)
+ ).to_be_visible()
+
+ # Select the second additional collection
+ page.locator(".select2-container").click()
+ select2_input = page.locator(".select2-search__field")
+ expect(select2_input).to_be_visible()
+ select2_input.press_sequentially(extra_collection_b.name[:5])
+
+ result_b = page.locator(".select2-results__option", has_text=extra_collection_b.name)
+ expect(result_b).to_be_visible()
+ result_b.click()
+
+ expect(
+ page.locator(".select2-selection__choice", has_text=extra_collection_b.name)
+ ).to_be_visible()
+
+ # Submit with confirmation dialog
+ page.on("dialog", lambda dialog: dialog.accept())
+ page.get_by_role("button", name="Publish 1 accessions").click()
+
+ page.wait_for_url(f"**{reverse('cohort-detail', args=[cohort.pk])}")
+ expect(page.get_by_text("Publishing 1 image")).to_be_visible()
diff --git a/isic/ingest/tests/test_review_browser.py b/isic/ingest/tests/test_review_browser.py
new file mode 100644
index 000000000..29b29ebbe
--- /dev/null
+++ b/isic/ingest/tests/test_review_browser.py
@@ -0,0 +1,82 @@
+import re
+
+from django.urls import reverse
+from playwright.sync_api import expect
+import pytest
+
+
+@pytest.mark.playwright
+def test_review_gallery_reject_toggle_and_submit(
+ staff_authenticated_page, cohort_factory, accession_factory
+):
+ page = staff_authenticated_page
+
+ cohort = cohort_factory()
+ accessions = [accession_factory(cohort=cohort, ingested=True) for _ in range(3)]
+
+ page.goto(reverse("cohort-review", args=[cohort.pk]))
+
+ # All 3 accessions should be visible with reject buttons
+ reject_buttons = page.get_by_role("button", name="Reject")
+ expect(reject_buttons).to_have_count(3)
+
+ # Toggle reject on the first accession -- the button should get the "dim" class
+ first_reject = reject_buttons.first
+ first_reject.click()
+ expect(first_reject).to_have_class(re.compile(r"dim"))
+
+ # Toggle it again -- the dim class should be removed
+ first_reject.click()
+ expect(first_reject).not_to_have_class(re.compile(r"dim"))
+
+ # Reject the first accession for real this time, leave the others as accept
+ first_reject.click()
+ expect(first_reject).to_have_class(re.compile(r"dim"))
+
+ # Click "Accept remaining" to submit all reviews
+ page.get_by_role("button", name="Accept remaining").click()
+
+ # The page reloads and all accessions are now reviewed
+ page.wait_for_load_state("networkidle")
+ expect(page.locator("text=No accessions left to review!")).to_be_visible()
+
+ # Verify the database state: first was rejected, others accepted
+ accessions[0].refresh_from_db()
+ assert accessions[0].review.value is False
+ for acc in accessions[1:]:
+ acc.refresh_from_db()
+ assert acc.review.value is True
+
+
+@pytest.mark.playwright
+def test_review_gallery_accession_modal_and_metadata_tabs(
+ staff_authenticated_page, cohort_factory, accession_factory
+):
+ page = staff_authenticated_page
+
+ cohort = cohort_factory()
+ accession = accession_factory(cohort=cohort, ingested=True)
+
+ page.goto(reverse("cohort-review", args=[cohort.pk]))
+
+ # Click the thumbnail image to open the modal
+ page.locator("img").first.click()
+
+ # The modal should appear showing the original filename
+ modal = page.get_by_role("dialog")
+ expect(modal).to_be_visible()
+ expect(modal.get_by_text(accession.original_blob_name)).to_be_visible()
+
+ # Switch to "Unstructured Metadata" tab
+ modal.get_by_role("link", name="Unstructured Metadata").click()
+ unstructured_tab = modal.locator("pre").nth(1)
+ expect(unstructured_tab).to_be_visible()
+
+ # Switch back to "Metadata" tab
+ modal.get_by_role("link", name="Metadata", exact=True).click()
+ metadata_tab = modal.locator("pre").first
+ expect(metadata_tab).to_be_visible()
+
+ # Close the modal
+ modal.get_by_role("button", name="Close").click()
+ expect(modal).not_to_be_visible()
diff --git a/isic/studies/tests/test_diagnosis_picker_browser.py b/isic/studies/tests/test_diagnosis_picker_browser.py
new file mode 100644
index 000000000..af22edd22
--- /dev/null
+++ b/isic/studies/tests/test_diagnosis_picker_browser.py
@@ -0,0 +1,114 @@
+from django.urls import reverse
+from playwright.sync_api import expect
+import pytest
+
+from isic.core.services.collection.image import collection_add_images
+from isic.studies.models import Question, QuestionChoice, Response, StudyTask
+
+
+@pytest.mark.playwright
+def test_diagnosis_picker_search_expand_select_and_submit(
+ authenticated_page, authenticated_user, collection_factory, image_factory, study_factory
+):
+ page = authenticated_page
+ user = authenticated_user
+
+ collection = collection_factory(creator=user)
+ image = image_factory(public=True)
+ collection_add_images(collection=collection, image=image)
+
+ # Create a diagnosis question with hierarchical choices
+ question = Question.objects.create(
+ prompt="What is the diagnosis?", type=Question.QuestionType.DIAGNOSIS, official=False
+ )
+ choices = {}
+ for path in [
+ "Benign:Nevus:Blue Nevus",
+ "Benign:Nevus:Junctional",
+ "Benign:Nevus:Compound",
+ "Benign:Dermatofibroma",
+ "Malignant:Melanoma:Superficial Spreading",
+ "Malignant:Melanoma:Nodular",
+ "Malignant:Basal Cell Carcinoma",
+ ]:
+ choices[path] = QuestionChoice.objects.create(question=question, text=path)
+
+ study = study_factory(
+ creator=user,
+ collection=collection,
+ public=False,
+ questions=[question],
+ questions__required=True,
+ )
+
+ task = StudyTask.objects.create(study=study, annotator=user, image=image)
+
+ page.goto(reverse("study-task-detail", args=[task.pk]))
+
+ # The picker should be visible with its search input and heading
+ expect(page.get_by_role("heading", name="Recent Diagnoses")).to_be_visible()
+ search_input = page.get_by_placeholder("Search diagnoses...")
+ expect(search_input).to_be_visible()
+
+ # The top-level nodes should be visible: Benign, Malignant.
+ # Wait for the tree toggle arrows to appear, which indicates JS has finished
+ # building the tree and hiding child nodes.
+ expect(page.get_by_text("Benign", exact=True)).to_be_visible()
+ expect(page.get_by_text("Malignant", exact=True)).to_be_visible()
+ expect(page.locator(".tree-toggle", has_text="\u25b6").first).to_be_visible()
+
+ # Child nodes should be hidden initially
+ expect(page.get_by_text("Nevus", exact=True)).not_to_be_visible()
+
+ # Expand "Benign" by clicking its toggle
+ page.get_by_text("Benign", exact=True).locator("xpath=preceding-sibling::span").click()
+ expect(page.get_by_text("Nevus", exact=True)).to_be_visible()
+ expect(page.get_by_text("Dermatofibroma", exact=True)).to_be_visible()
+
+ # Expand "Nevus"
+ page.get_by_text("Nevus", exact=True).locator("xpath=preceding-sibling::span").click()
+ expect(page.get_by_text("Blue Nevus", exact=True)).to_be_visible()
+
+ # Collapse "Benign"
+ page.get_by_text("Benign", exact=True).locator("xpath=preceding-sibling::span").click()
+ expect(page.get_by_text("Nevus", exact=True)).not_to_be_visible()
+
+ # -- Search filtering --
+ search_input.fill("Nodular")
+
+ # Only the matching path should be visible: Malignant > Melanoma > Nodular
+ expect(page.get_by_text("Nodular", exact=True)).to_be_visible()
+ expect(page.get_by_text("Melanoma", exact=True)).to_be_visible()
+ expect(page.get_by_text("Malignant", exact=True)).to_be_visible()
+
+ # Non-matching nodes should be hidden
+ expect(page.get_by_text("Benign", exact=True)).not_to_be_visible()
+ expect(page.get_by_text("Basal Cell Carcinoma", exact=True)).not_to_be_visible()
+
+ # Clear search
+ search_input.fill("")
+
+ # All top-level nodes should be visible again
+ expect(page.get_by_text("Benign", exact=True)).to_be_visible()
+ expect(page.get_by_text("Malignant", exact=True)).to_be_visible()
+
+ # -- Select a diagnosis via search --
+ search_input.fill("Junctional")
+ junctional = page.get_by_text("Junctional", exact=True)
+ expect(junctional).to_be_visible()
+ junctional.click()
+
+ # Selection indicator should appear
+ expect(page.get_by_text("Selected:")).to_be_visible()
+ expect(page.get_by_text("Benign:Nevus:Junctional")).to_be_visible()
+
+ # Submit the annotation
+ page.get_by_role("button", name="Respond and continue").click()
+
+ # Should redirect to the study detail page (no more tasks)
+ page.wait_for_url(f"**{reverse('study-detail', args=[study.pk])}")
+
+ # Verify the response was saved in the database
+ response = Response.objects.get(annotation__study=study, annotation__annotator=user)
+ assert response.choice == choices["Benign:Nevus:Junctional"]
+ assert response.question == question
diff --git a/isic/studies/tests/test_multiselect_browser.py b/isic/studies/tests/test_multiselect_browser.py
new file mode 100644
index 000000000..51d8086ac
--- /dev/null
+++ b/isic/studies/tests/test_multiselect_browser.py
@@ -0,0 +1,104 @@
+from django.urls import reverse
+from playwright.sync_api import expect
+import pytest
+
+from isic.core.services.collection.image import collection_add_images
+from isic.studies.models import Annotation, Question, QuestionChoice, StudyTask
+
+
+@pytest.mark.playwright
+def test_multiselect_picker_search_select_all_and_submit(
+ authenticated_page, authenticated_user, collection_factory, image_factory, study_factory
+):
+ page = authenticated_page
+ user = authenticated_user
+
+ collection = collection_factory(creator=user)
+ image = image_factory(public=True)
+ collection_add_images(collection=collection, image=image)
+
+ question = Question.objects.create(
+ prompt="Select applicable features",
+ type=Question.QuestionType.MULTISELECT,
+ official=False,
+ )
+ choice_texts = [
+ "Asymmetry",
+ "Border irregularity",
+ "Color variation",
+ "Diameter > 6mm",
+ "Evolving",
+ "Blue-white veil",
+ "Atypical network",
+ ]
+ for text in choice_texts:
+ QuestionChoice.objects.create(question=question, text=text)
+
+ study = study_factory(
+ creator=user,
+ collection=collection,
+ public=False,
+ questions=[question],
+ questions__required=True,
+ )
+
+ task = StudyTask.objects.create(study=study, annotator=user, image=image)
+
+ page.goto(reverse("study-task-detail", args=[task.pk]))
+
+ expect(page.get_by_text("Select applicable features")).to_be_visible()
+
+ # All choices should be visible
+ for text in choice_texts:
+ expect(page.get_by_text(text, exact=True)).to_be_visible()
+
+ # Initially 0 selected
+ expect(page.get_by_text("0 selected")).to_be_visible()
+
+ # Toggle individual checkboxes
+ page.get_by_label("Asymmetry").check()
+ page.get_by_label("Color variation").check()
+ expect(page.get_by_text("2 selected")).to_be_visible()
+
+ # Uncheck one
+ page.get_by_label("Asymmetry").uncheck()
+ expect(page.get_by_text("1 selected")).to_be_visible()
+
+ # Search to filter choices
+ search_input = page.get_by_placeholder("Search options...")
+ search_input.fill("Blue")
+ expect(page.get_by_text("Blue-white veil", exact=True)).to_be_visible()
+ expect(page.get_by_text("Asymmetry", exact=True)).not_to_be_visible()
+
+ # "Select All Visible" should select only the filtered results
+ page.get_by_role("button", name="Select All Visible").click()
+ expect(page.get_by_text("2 selected")).to_be_visible()
+
+ # Clear search to see all choices again
+ search_input.fill("")
+
+ # Clear all selections
+ page.get_by_role("button", name="Clear All").click()
+ expect(page.get_by_text("0 selected")).to_be_visible()
+
+ # Select specific choices for submission
+ page.get_by_label("Border irregularity").check()
+ page.get_by_label("Diameter > 6mm").check()
+ page.get_by_label("Evolving").check()
+ expect(page.get_by_text("3 selected")).to_be_visible()
+
+ # Submit the annotation
+ page.get_by_role("button", name="Respond and continue").click()
+
+ # Should redirect to study detail (only 1 task, so study is complete)
+ page.wait_for_url(f"**{reverse('study-detail', args=[study.pk])}")
+ expect(page.get_by_text("completed all tasks")).to_be_visible()
+
+ # Verify responses in the database
+ annotation = Annotation.objects.get(task=task)
+ response = annotation.responses.get(question=question)
+ selected_pks = response.value["choices"]
+ selected_texts = set(
+ QuestionChoice.objects.filter(pk__in=selected_pks).values_list("text", flat=True)
+ )
+ assert selected_texts == {"Border irregularity", "Diameter > 6mm", "Evolving"}
diff --git a/isic/studies/tests/test_study_browser.py b/isic/studies/tests/test_study_browser.py
new file mode 100644
index 000000000..662fa1b97
--- /dev/null
+++ b/isic/studies/tests/test_study_browser.py
@@ -0,0 +1,115 @@
+from django.urls import reverse
+from playwright.sync_api import expect
+import pytest
+
+from isic.core.services.collection.image import collection_add_images
+from isic.studies.models import Study
+from isic.studies.tests.factories import QuestionFactory
+
+
+@pytest.mark.playwright
+def test_study_create_with_official_and_custom_questions( # noqa: PLR0915
+ authenticated_page, authenticated_user, collection_factory, image_factory
+):
+ page, user = authenticated_page, authenticated_user
+
+ collection = collection_factory(creator=user, locked=False)
+ for _ in range(3):
+ collection_add_images(collection=collection, image=image_factory(public=True))
+
+ official_questions = [QuestionFactory.create(official=True) for _ in range(3)]
+
+ page.goto(reverse("study-create"))
+
+ # Fill in the base form
+ study_name = f"Study {collection.name}"
+ study_description = f"A study for {collection.name}."
+ study_attribution = f"Institution {collection.name}"
+ page.get_by_label("Name").fill(study_name)
+ page.get_by_label("Description").fill(study_description)
+ page.get_by_label("Attribution").fill(study_attribution)
+ page.get_by_label("Collection").select_option(str(collection.pk))
+ page.get_by_label("Annotators").fill(user.email)
+
+ # -- Official question picker modal --
+ page.get_by_text("Add Official Question").click()
+ modal = page.get_by_role("dialog")
+ expect(modal).to_be_visible()
+
+ # All official questions should be listed
+ for q in official_questions:
+ expect(modal.get_by_text(q.prompt)).to_be_visible()
+
+ # Filter by typing part of the first question's prompt
+ target_question = official_questions[0]
+ page.get_by_placeholder("Filter questions").fill(target_question.prompt[:10])
+
+ # Only the matching question should remain visible
+ expect(modal.get_by_text(target_question.prompt)).to_be_visible()
+
+ # Click "Use" to add the question
+ modal.get_by_role("link", name="Use").first.click()
+
+ # The question should now show "Used" instead of "Use"
+ expect(modal.get_by_text("Used")).to_be_visible()
+
+ # Close the modal
+ modal.get_by_role("button", name="Close").click()
+ expect(modal).not_to_be_visible()
+
+ # The official question should appear in the form with prompt and choices displayed
+ expect(page.get_by_text(target_question.prompt).first).to_be_visible()
+
+ # -- Custom question formset --
+ page.get_by_text("Add Custom Question").click()
+
+ # Fill in the custom question fields. Use .last because the first match is the
+ # hidden empty form template used for cloning.
+ custom_prompt = f"Question about {collection.name}?"
+ custom_choices = ["Alpha", "Beta", "Gamma"]
+ page.get_by_label("Prompt").last.fill(custom_prompt)
+ page.get_by_label("Question type").last.select_option("select")
+ page.get_by_label("Choices").last.fill("\n".join(custom_choices))
+
+ # Add a second custom question and then remove it
+ page.get_by_text("Add Custom Question").click()
+ custom_section = page.get_by_text("Add Custom Question").locator("xpath=../..")
+ remove_links = custom_section.get_by_role("link", name="Remove")
+ expect(remove_links).to_have_count(2)
+
+ remove_links.nth(1).click()
+ expect(custom_section.get_by_role("link", name="Remove")).to_have_count(1)
+
+ # Submit the form
+ page.get_by_role("button", name="Create Study").click()
+
+ # Should redirect to the study detail page
+ page.wait_for_url("**/studies/*/")
+
+ # Verify the study was created with the correct properties
+ study = Study.objects.get(name=study_name)
+ assert page.url.endswith(reverse("study-detail", args=[study.pk]))
+ assert study.description == study_description
+ assert study.attribution == study_attribution
+ assert study.collection == collection
+ assert study.creator == user
+ assert study.public is False
+
+ # The collection should be locked after study creation
+ collection.refresh_from_db()
+ assert collection.locked is True
+
+ # 2 questions: 1 official + 1 custom
+ assert study.questions.count() == 2
+ assert study.questions.filter(official=True).first() == target_question
+
+ custom_question = study.questions.get(official=False)
+ assert custom_question.prompt == custom_prompt
+ assert list(custom_question.choices.values_list("text", flat=True).order_by("text")) == sorted(
+ custom_choices
+ )
+
+ # Study tasks: one per image per annotator
+ assert study.tasks.count() == 3
+
+ expect(page.get_by_text(study.name).first).to_be_visible()
diff --git a/isic/studies/tests/test_undo_browser.py b/isic/studies/tests/test_undo_browser.py
new file mode 100644
index 000000000..1e5cf3e5b
--- /dev/null
+++ b/isic/studies/tests/test_undo_browser.py
@@ -0,0 +1,63 @@
+from django.urls import reverse
+from playwright.sync_api import expect
+import pytest
+
+from isic.core.services.collection.image import collection_add_images
+from isic.studies.models import Annotation, Question, QuestionChoice, StudyTask
+
+
+@pytest.mark.playwright
+def test_study_task_undo_after_annotation(
+ authenticated_page, authenticated_user, collection_factory, image_factory, study_factory
+):
+ page = authenticated_page
+ user = authenticated_user
+
+ collection = collection_factory(creator=user)
+ images = [image_factory(public=True) for _ in range(2)]
+ for img in images:
+ collection_add_images(collection=collection, image=img)
+
+ question = Question.objects.create(
+ prompt="Is this benign?", type=Question.QuestionType.SELECT, official=False
+ )
+ QuestionChoice.objects.create(question=question, text="Yes")
+ QuestionChoice.objects.create(question=question, text="No")
+
+ study = study_factory(
+ creator=user,
+ collection=collection,
+ public=False,
+ questions=[question],
+ questions__required=True,
+ )
+
+ tasks = [StudyTask.objects.create(study=study, annotator=user, image=img) for img in images]
+
+ # Navigate to the first task
+ page.goto(reverse("study-task-detail", args=[tasks[0].pk]))
+
+ # The form should show the question with radio buttons
+ expect(page.get_by_text("Is this benign?")).to_be_visible()
+ page.get_by_label("Yes").check()
+
+ # Submit the annotation
+ page.get_by_role("button", name="Respond and continue").click()
+
+ # Should redirect to the second task, with an "Undo" toast visible
+ page.wait_for_url(f"**{reverse('study-task-detail', args=[tasks[1].pk])}")
+ expect(page.get_by_role("link", name="Undo")).to_be_visible()
+
+ # Verify the first task's annotation exists
+ assert Annotation.objects.filter(task=tasks[0]).exists()
+
+ # Click Undo
+ page.get_by_role("link", name="Undo").click()
+
+ # Should redirect back to the first task (annotation deleted, form visible again)
+ page.wait_for_url(f"**{reverse('study-task-detail', args=[tasks[0].pk])}")
+ expect(page.get_by_text("Is this benign?")).to_be_visible()
+ expect(page.get_by_role("button", name="Respond and continue")).to_be_visible()
+
+ # Verify the annotation was deleted
+ assert not Annotation.objects.filter(task=tasks[0]).exists()