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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/partials/get_started_launchpad.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
175 changes: 102 additions & 73 deletions src/aignostics/application/_gui/_page_application_describe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...] = (
Comment thread
olivermeyer marked this conversation as resolved.
"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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"},
Expand Down
124 changes: 124 additions & 0 deletions tests/aignostics/application/page_application_describe_test.py
Original file line number Diff line number Diff line change
@@ -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
Loading