From 85974be9960b5164828e67e129b4dc3bc2c18e46 Mon Sep 17 00:00:00 2001 From: omid-aignostics Date: Wed, 29 Jul 2026 17:56:58 +0200 Subject: [PATCH 1/4] feat(application): expose pancreatic, prostate & stomach cancer indications in run submission GUI [PYSDK-149] The run-submission GUI hardcoded the per-slide Tissue and Disease metadata dropdowns, which had drifted behind the platform API. The Atlas H&E-TME v1.2.0 schema now supports prostate, pancreatic and stomach cancer specimen types. Sync the GUI dropdowns with the whole_slide_image input-artifact metadata_schema: - Diseases added: PROSTATE_CANCER, PANCREATIC_CANCER, STOMACH_CANCER - Tissues added: PANCREAS, PROSTATE, SPLEEN, STOMACH Consolidate the previously 4x-duplicated literal lists into single-source-of-truth TISSUE_TYPES / DISEASE_TYPES constants used by validation, the dropdown editors, and the cell-coloring rules, so the lists can no longer drift apart. Co-Authored-By: Claude Opus 4.8 --- .../_gui/_page_application_describe.py | 104 +++++++++--------- 1 file changed, 53 insertions(+), 51 deletions(-) diff --git a/src/aignostics/application/_gui/_page_application_describe.py b/src/aignostics/application/_gui/_page_application_describe.py index dac782684..7227f8bae 100644 --- a/src/aignostics/application/_gui/_page_application_describe.py +++ b/src/aignostics/application/_gui/_page_application_describe.py @@ -43,6 +43,52 @@ CLASS_WIDTH_ONE_HALF = "w-1/2" DATETIME_MASK = "YYYY-MM-DD HH:mm" +# Supported specimen tissue and disease (indication) values for per-slide metadata. +# Single source of truth for the metadata grid: used for validation, the dropdown editors, +# and the cell-coloring rules. Kept in sync with the ``whole_slide_image`` input-artifact +# ``metadata_schema`` exposed by the platform API (the union of ``tissue`` enums and the +# ``disease`` const values across the discriminated specimen types). +TISSUE_TYPES: tuple[str, ...] = ( + "ADRENAL_GLAND", + "BLADDER", + "BONE", + "BRAIN", + "BREAST", + "COLON", + "LIVER", + "LUNG", + "LYMPH_NODE", + "OTHER", + "PANCREAS", + "PROSTATE", + "SPLEEN", + "STOMACH", +) +DISEASE_TYPES: tuple[str, ...] = ( + "BLADDER_CANCER", + "BREAST_CANCER", + "COLORECTAL_CANCER", + "LIVER_CANCER", + "LUNG_CANCER", + "PANCREATIC_CANCER", + "PROSTATE_CANCER", + "STOMACH_CANCER", +) + + +def _js_set_membership(values: tuple[str, ...], *, negate: bool = False) -> str: + """Build an AG Grid ``cellClassRules`` expression testing whether edited value ``x`` is in ``values``. + + Args: + values: Allowed values. + negate: If True, the expression is true when ``x`` is *not* in ``values``. + + Returns: + A JavaScript expression string, e.g. ``new Set(['LUNG', 'OTHER']).has(x)``. + """ + elements = ", ".join(f"'{value}'" for value in values) + return f"{'!' if negate else ''}new Set([{elements}]).has(x)" + @binding.bindable_dataclass class SubmitForm: @@ -438,30 +484,7 @@ async def _validate() -> None: rows = await submit_form.metadata_grid.get_client_data() valid = True for row in rows: - if ( - row["tissue"] - not in { - "ADRENAL_GLAND", - "BLADDER", - "BONE", - "BRAIN", - "BREAST", - "COLON", - "LIVER", - "LUNG", - "LYMPH_NODE", - "OTHER", - } - ) or ( - row["disease"] - not in { - "BREAST_CANCER", - "BLADDER_CANCER", - "COLORECTAL_CANCER", - "LIVER_CANCER", - "LUNG_CANCER", - } - ): + if (row["tissue"] not in set(TISSUE_TYPES)) or (row["disease"] not in set(DISEASE_TYPES)): valid = False break if submit_form.metadata_next_button is None: @@ -550,25 +573,12 @@ class ThumbnailRenderer { "editable": True, "cellEditor": "agSelectCellEditor", "cellEditorParams": { - "values": [ - "ADRENAL_GLAND", - "BLADDER", - "BONE", - "BRAIN", - "BREAST", - "COLON", - "LIVER", - "LUNG", - "LYMPH_NODE", - "OTHER", - ], + "values": list(TISSUE_TYPES), "valueListGap": 10, }, "cellClassRules": { - "bg-red-300": "!new Set(['ADRENAL_GLAND', 'BLADDER', 'BONE', 'BRAIN'," - "'BREAST', 'COLON', 'LIVER', 'LUNG', 'LYMPH_NODE', 'OTHER']).has(x)", - "bg-green-300": "new Set(['ADRENAL_GLAND', 'BLADDER', 'BONE', 'BRAIN'," - "'BREAST', 'COLON', 'LIVER', 'LUNG', 'LYMPH_NODE', 'OTHER']).has(x)", + "bg-red-300": _js_set_membership(TISSUE_TYPES, negate=True), + "bg-green-300": _js_set_membership(TISSUE_TYPES), }, }, { @@ -577,20 +587,12 @@ class ThumbnailRenderer { "editable": True, "cellEditor": "agSelectCellEditor", "cellEditorParams": { - "values": [ - "BREAST_CANCER", - "BLADDER_CANCER", - "COLORECTAL_CANCER", - "LIVER_CANCER", - "LUNG_CANCER", - ], + "values": list(DISEASE_TYPES), "valueListGap": 10, }, "cellClassRules": { - "bg-red-300": "!new Set(['BREAST_CANCER', 'BLADDER_CANCER', " - "'COLORECTAL_CANCER', 'LIVER_CANCER', 'LUNG_CANCER']).has(x)", - "bg-green-300": "new Set(['BREAST_CANCER', 'BLADDER_CANCER', " - "'COLORECTAL_CANCER', 'LIVER_CANCER', 'LUNG_CANCER']).has(x)", + "bg-red-300": _js_set_membership(DISEASE_TYPES, negate=True), + "bg-green-300": _js_set_membership(DISEASE_TYPES), }, }, {"headerName": "File size", "field": "file_size_human"}, From 8bf40ee89bbba9da823c1b30fed0c97459bb8adc Mon Sep 17 00:00:00 2001 From: omid-aignostics Date: Thu, 30 Jul 2026 08:53:25 +0200 Subject: [PATCH 2/4] test(application): cover specimen indication helpers; extract pure column-def/validation helpers [PYSDK-149] CI SonarCloud quality gate failed with 0% new-code coverage because _page_application_describe.py is not imported by any unit/integration test (only the skipped long_running submit e2e exercises it). Extract the new logic into pure, module-level helpers so it can be unit-tested without building the NiceGUI page: - _specimen_metadata_column_defs(): the Tissue/Disease AG Grid columns - _metadata_row_is_valid(): the per-row tissue/disease validity check Add tests/aignostics/application/page_application_describe_test.py with unit tests for TISSUE_TYPES/DISEASE_TYPES, _js_set_membership, _specimen_metadata_column_defs and _metadata_row_is_valid, including the newly supported prostate/pancreatic/stomach indications. Co-Authored-By: Claude Opus 4.8 --- .../_gui/_page_application_describe.py | 85 +++++++---- .../page_application_describe_test.py | 139 ++++++++++++++++++ 2 files changed, 195 insertions(+), 29 deletions(-) create mode 100644 tests/aignostics/application/page_application_describe_test.py diff --git a/src/aignostics/application/_gui/_page_application_describe.py b/src/aignostics/application/_gui/_page_application_describe.py index 7227f8bae..55dbc2d62 100644 --- a/src/aignostics/application/_gui/_page_application_describe.py +++ b/src/aignostics/application/_gui/_page_application_describe.py @@ -90,6 +90,60 @@ def _js_set_membership(values: tuple[str, ...], *, negate: bool = False) -> str: return f"{'!' if negate else ''}new Set([{elements}]).has(x)" +def _metadata_row_is_valid(row: dict[str, Any]) -> bool: + """Return whether a metadata grid row has a supported tissue and disease (indication). + + Args: + row: A metadata grid row; the ``"tissue"`` and ``"disease"`` values are checked. + + Returns: + True if both ``tissue`` and ``disease`` are in the supported sets, False otherwise. + """ + return row.get("tissue") in TISSUE_TYPES and row.get("disease") in DISEASE_TYPES + + +def _specimen_metadata_column_defs() -> list[dict[str, Any]]: + """Build the AG Grid column definitions for the editable Tissue and Disease columns. + + The dropdown values and the red/green cell-coloring rules are derived from the single + source of truth :data:`TISSUE_TYPES` / :data:`DISEASE_TYPES`, so the run-submission grid + stays in sync with the platform API's supported specimen indications. + + Returns: + The Tissue and Disease column definition dicts, in display order. + """ + return [ + { + "headerName": "Tissue", + "field": "tissue", + "editable": True, + "cellEditor": "agSelectCellEditor", + "cellEditorParams": { + "values": list(TISSUE_TYPES), + "valueListGap": 10, + }, + "cellClassRules": { + "bg-red-300": _js_set_membership(TISSUE_TYPES, negate=True), + "bg-green-300": _js_set_membership(TISSUE_TYPES), + }, + }, + { + "headerName": "Disease", + "field": "disease", + "editable": True, + "cellEditor": "agSelectCellEditor", + "cellEditorParams": { + "values": list(DISEASE_TYPES), + "valueListGap": 10, + }, + "cellClassRules": { + "bg-red-300": _js_set_membership(DISEASE_TYPES, negate=True), + "bg-green-300": _js_set_membership(DISEASE_TYPES), + }, + }, + ] + + @binding.bindable_dataclass class SubmitForm: """Submit form.""" @@ -484,7 +538,7 @@ async def _validate() -> None: rows = await submit_form.metadata_grid.get_client_data() valid = True for row in rows: - if (row["tissue"] not in set(TISSUE_TYPES)) or (row["disease"] not in set(DISEASE_TYPES)): + if not _metadata_row_is_valid(row): valid = False break if submit_form.metadata_next_button is None: @@ -567,34 +621,7 @@ class ThumbnailRenderer { ":cellRenderer": thumbnail_renderer_js, "autoHeight": True, }, - { - "headerName": "Tissue", - "field": "tissue", - "editable": True, - "cellEditor": "agSelectCellEditor", - "cellEditorParams": { - "values": list(TISSUE_TYPES), - "valueListGap": 10, - }, - "cellClassRules": { - "bg-red-300": _js_set_membership(TISSUE_TYPES, negate=True), - "bg-green-300": _js_set_membership(TISSUE_TYPES), - }, - }, - { - "headerName": "Disease", - "field": "disease", - "editable": True, - "cellEditor": "agSelectCellEditor", - "cellEditorParams": { - "values": list(DISEASE_TYPES), - "valueListGap": 10, - }, - "cellClassRules": { - "bg-red-300": _js_set_membership(DISEASE_TYPES, negate=True), - "bg-green-300": _js_set_membership(DISEASE_TYPES), - }, - }, + *_specimen_metadata_column_defs(), {"headerName": "File size", "field": "file_size_human"}, {"headerName": "MPP", "field": "resolution_mpp"}, {"headerName": "Width", "field": "width_px"}, diff --git a/tests/aignostics/application/page_application_describe_test.py b/tests/aignostics/application/page_application_describe_test.py new file mode 100644 index 000000000..e52ac938f --- /dev/null +++ b/tests/aignostics/application/page_application_describe_test.py @@ -0,0 +1,139 @@ +"""Unit tests for the pure metadata helpers of the application describe/submit page. + +These cover the single-source-of-truth specimen indication lists and the pure helpers +derived from them (validation, dropdown/column definitions, cell-coloring expressions), +without building the NiceGUI page itself. +""" + +import pytest + +from aignostics.application._gui._page_application_describe import ( + DISEASE_TYPES, + TISSUE_TYPES, + _js_set_membership, + _metadata_row_is_valid, + _specimen_metadata_column_defs, +) + +# Indications added in Atlas H&E-TME v1.2.0 ("Added prostate, pancreatic, and stomach cancer types"). +_NEWLY_SUPPORTED_DISEASES = ("PROSTATE_CANCER", "PANCREATIC_CANCER", "STOMACH_CANCER") +_NEWLY_SUPPORTED_TISSUES = ("PANCREAS", "PROSTATE", "SPLEEN", "STOMACH") + + +@pytest.mark.unit +def test_supported_indication_lists_match_platform_api() -> None: + """The GUI lists mirror the platform API's whole_slide_image metadata_schema.""" + assert set(TISSUE_TYPES) == { + "ADRENAL_GLAND", + "BLADDER", + "BONE", + "BRAIN", + "BREAST", + "COLON", + "LIVER", + "LUNG", + "LYMPH_NODE", + "OTHER", + "PANCREAS", + "PROSTATE", + "SPLEEN", + "STOMACH", + } + assert set(DISEASE_TYPES) == { + "BLADDER_CANCER", + "BREAST_CANCER", + "COLORECTAL_CANCER", + "LIVER_CANCER", + "LUNG_CANCER", + "PANCREATIC_CANCER", + "PROSTATE_CANCER", + "STOMACH_CANCER", + } + + +@pytest.mark.unit +def test_newly_supported_indications_are_present() -> None: + """The prostate/pancreatic/stomach additions are selectable.""" + for disease in _NEWLY_SUPPORTED_DISEASES: + assert disease in DISEASE_TYPES + for tissue in _NEWLY_SUPPORTED_TISSUES: + assert tissue in TISSUE_TYPES + + +@pytest.mark.unit +def test_indication_lists_have_no_duplicates() -> None: + """A single source of truth must not carry duplicate entries.""" + assert len(TISSUE_TYPES) == len(set(TISSUE_TYPES)) + assert len(DISEASE_TYPES) == len(set(DISEASE_TYPES)) + + +@pytest.mark.unit +def test_js_set_membership_positive() -> None: + """Without negation the expression is a plain Set membership test on x.""" + assert _js_set_membership(("LUNG", "OTHER")) == "new Set(['LUNG', 'OTHER']).has(x)" + + +@pytest.mark.unit +def test_js_set_membership_negated() -> None: + """With negation the expression is prefixed with the JS not operator.""" + assert _js_set_membership(("LUNG", "OTHER"), negate=True) == "!new Set(['LUNG', 'OTHER']).has(x)" + + +@pytest.mark.unit +def test_js_set_membership_covers_all_current_values() -> None: + """Every supported value is embedded in the generated expressions.""" + tissue_expr = _js_set_membership(TISSUE_TYPES) + disease_expr = _js_set_membership(DISEASE_TYPES) + for tissue in TISSUE_TYPES: + assert f"'{tissue}'" in tissue_expr + for disease in DISEASE_TYPES: + assert f"'{disease}'" in disease_expr + + +@pytest.mark.unit +def test_specimen_column_defs_structure() -> None: + """The helper returns the Tissue and Disease columns wired to the supported lists.""" + columns = _specimen_metadata_column_defs() + assert [c["field"] for c in columns] == ["tissue", "disease"] + + tissue_col, disease_col = columns + assert tissue_col["cellEditorParams"]["values"] == list(TISSUE_TYPES) + assert disease_col["cellEditorParams"]["values"] == list(DISEASE_TYPES) + + for col, values in ((tissue_col, TISSUE_TYPES), (disease_col, DISEASE_TYPES)): + assert col["editable"] is True + assert col["cellEditor"] == "agSelectCellEditor" + assert col["cellClassRules"]["bg-green-300"] == _js_set_membership(values) + assert col["cellClassRules"]["bg-red-300"] == _js_set_membership(values, negate=True) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("tissue", "disease"), + [ + ("LUNG", "LUNG_CANCER"), + ("PROSTATE", "PROSTATE_CANCER"), + ("PANCREAS", "PANCREATIC_CANCER"), + ("STOMACH", "STOMACH_CANCER"), + ("LYMPH_NODE", "COLORECTAL_CANCER"), + ], +) +def test_metadata_row_is_valid_accepts_supported_combinations(tissue: str, disease: str) -> None: + """Rows with supported tissue and disease pass validation.""" + assert _metadata_row_is_valid({"tissue": tissue, "disease": disease}) is True + + +@pytest.mark.unit +@pytest.mark.parametrize( + "row", + [ + {"tissue": "LUNG", "disease": "UNSUPPORTED_CANCER"}, + {"tissue": "UNSUPPORTED_TISSUE", "disease": "LUNG_CANCER"}, + {"tissue": "OTHER", "disease": None}, + {"tissue": None, "disease": "LUNG_CANCER"}, + {}, + ], +) +def test_metadata_row_is_valid_rejects_unsupported_or_missing(row: dict) -> None: + """Rows with an unsupported or missing tissue/disease fail validation.""" + assert _metadata_row_is_valid(row) is False From b31f9ea7c89e1093e879be48dcf807dac8f86a5c Mon Sep 17 00:00:00 2001 From: omid-aignostics Date: Thu, 30 Jul 2026 09:53:09 +0200 Subject: [PATCH 3/4] docs(launchpad): warn users to stay on page until upload completes in step 5 [PYSDK-149] Clarify in the "Submit the analysis" step that the run is only created once the upload finishes and the run appears in the sidebar. Leaving the page, closing Launchpad, or letting the machine sleep before the upload completes means no run is created and the submission must be restarted. Co-Authored-By: Claude Opus 4.8 --- docs/partials/get_started_launchpad.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/partials/get_started_launchpad.md b/docs/partials/get_started_launchpad.md index 71014b220..9a6281a31 100644 --- a/docs/partials/get_started_launchpad.md +++ b/docs/partials/get_started_launchpad.md @@ -101,6 +101,8 @@ After the slide table, Launchpad shows a few optional screens — one for notes The submission screen shows how many slides will be analyzed and where they are. Click **UPLOAD AND SUBMIT**. A progress bar shows your slide uploading to the Aignostics Platform. When the upload finishes, your run appears in the list on the left with a running icon (🏃). +**Stay on this page until the upload finishes and your run appears in the list on the left.** The run is only created once the upload completes. If you leave the page, close Launchpad, or let your computer go to sleep before the upload finishes, the run is not created and you will need to start the submission again. Once the run shows up in the list with the running icon (🏃), the upload is done — from that point the analysis continues on Aignostics servers and you can safely close Launchpad. + ### 6. Wait for results The analysis now runs on Aignostics servers, not on your computer, so you can do other things while you wait. How long it takes depends on the size and number of slides — anywhere from a few minutes to several hours. Click your run in the list on the left to see its status. The icon updates on its own as the slide is processed. From 6234a6cce8cc732fd7b26a1047545f7ce8f2f9ed Mon Sep 17 00:00:00 2001 From: omid-aignostics Date: Thu, 30 Jul 2026 11:42:25 +0200 Subject: [PATCH 4/4] test(application): fold indication-list assertions into one exact-equality check [PYSDK-149] Address review feedback: remove the redundant "newly supported present" and "no duplicates" tests. Assert exact tuple equality of TISSUE_TYPES / DISEASE_TYPES instead, which pins membership, dropdown display order, and absence of duplicates in a single assertion. Tuples (not sets) are kept deliberately so the dropdown display order is deterministic. Co-Authored-By: Claude Opus 4.8 --- .../page_application_describe_test.py | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/tests/aignostics/application/page_application_describe_test.py b/tests/aignostics/application/page_application_describe_test.py index e52ac938f..1b2224b35 100644 --- a/tests/aignostics/application/page_application_describe_test.py +++ b/tests/aignostics/application/page_application_describe_test.py @@ -15,15 +15,16 @@ _specimen_metadata_column_defs, ) -# Indications added in Atlas H&E-TME v1.2.0 ("Added prostate, pancreatic, and stomach cancer types"). -_NEWLY_SUPPORTED_DISEASES = ("PROSTATE_CANCER", "PANCREATIC_CANCER", "STOMACH_CANCER") -_NEWLY_SUPPORTED_TISSUES = ("PANCREAS", "PROSTATE", "SPLEEN", "STOMACH") - @pytest.mark.unit def test_supported_indication_lists_match_platform_api() -> None: - """The GUI lists mirror the platform API's whole_slide_image metadata_schema.""" - assert set(TISSUE_TYPES) == { + """The GUI lists mirror the platform API's whole_slide_image metadata_schema. + + Exact tuple equality pins the supported values (including the prostate/pancreatic/stomach + additions from Atlas H&E-TME v1.2.0), their dropdown display order, and the absence of + duplicates in one assertion. + """ + assert TISSUE_TYPES == ( "ADRENAL_GLAND", "BLADDER", "BONE", @@ -38,8 +39,8 @@ def test_supported_indication_lists_match_platform_api() -> None: "PROSTATE", "SPLEEN", "STOMACH", - } - assert set(DISEASE_TYPES) == { + ) + assert DISEASE_TYPES == ( "BLADDER_CANCER", "BREAST_CANCER", "COLORECTAL_CANCER", @@ -48,23 +49,7 @@ def test_supported_indication_lists_match_platform_api() -> None: "PANCREATIC_CANCER", "PROSTATE_CANCER", "STOMACH_CANCER", - } - - -@pytest.mark.unit -def test_newly_supported_indications_are_present() -> None: - """The prostate/pancreatic/stomach additions are selectable.""" - for disease in _NEWLY_SUPPORTED_DISEASES: - assert disease in DISEASE_TYPES - for tissue in _NEWLY_SUPPORTED_TISSUES: - assert tissue in TISSUE_TYPES - - -@pytest.mark.unit -def test_indication_lists_have_no_duplicates() -> None: - """A single source of truth must not carry duplicate entries.""" - assert len(TISSUE_TYPES) == len(set(TISSUE_TYPES)) - assert len(DISEASE_TYPES) == len(set(DISEASE_TYPES)) + ) @pytest.mark.unit