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. diff --git a/src/aignostics/application/_gui/_page_application_describe.py b/src/aignostics/application/_gui/_page_application_describe.py index dac782684..55dbc2d62 100644 --- a/src/aignostics/application/_gui/_page_application_describe.py +++ b/src/aignostics/application/_gui/_page_application_describe.py @@ -43,6 +43,106 @@ 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)" + + +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: @@ -438,30 +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 { - "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 not _metadata_row_is_valid(row): valid = False break if submit_form.metadata_next_button is None: @@ -544,55 +621,7 @@ class ThumbnailRenderer { ":cellRenderer": thumbnail_renderer_js, "autoHeight": True, }, - { - "headerName": "Tissue", - "field": "tissue", - "editable": True, - "cellEditor": "agSelectCellEditor", - "cellEditorParams": { - "values": [ - "ADRENAL_GLAND", - "BLADDER", - "BONE", - "BRAIN", - "BREAST", - "COLON", - "LIVER", - "LUNG", - "LYMPH_NODE", - "OTHER", - ], - "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)", - }, - }, - { - "headerName": "Disease", - "field": "disease", - "editable": True, - "cellEditor": "agSelectCellEditor", - "cellEditorParams": { - "values": [ - "BREAST_CANCER", - "BLADDER_CANCER", - "COLORECTAL_CANCER", - "LIVER_CANCER", - "LUNG_CANCER", - ], - "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)", - }, - }, + *_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..1b2224b35 --- /dev/null +++ b/tests/aignostics/application/page_application_describe_test.py @@ -0,0 +1,124 @@ +"""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, +) + + +@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. + + 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", + "BRAIN", + "BREAST", + "COLON", + "LIVER", + "LUNG", + "LYMPH_NODE", + "OTHER", + "PANCREAS", + "PROSTATE", + "SPLEEN", + "STOMACH", + ) + assert DISEASE_TYPES == ( + "BLADDER_CANCER", + "BREAST_CANCER", + "COLORECTAL_CANCER", + "LIVER_CANCER", + "LUNG_CANCER", + "PANCREATIC_CANCER", + "PROSTATE_CANCER", + "STOMACH_CANCER", + ) + + +@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