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()