From c5a4ac68ca9009c7dad2781b078bfb847844252e Mon Sep 17 00:00:00 2001 From: jyotsana15 Date: Mon, 30 Mar 2026 10:52:22 +0530 Subject: [PATCH 1/3] added subcellular localization feature --- backend/main.py | 38 ++- backend/tests/conftest.py | 11 + backend/tests/test_subcellular.py | 257 ++++++++++++++++ frontend/src/App.jsx | 8 + .../src/components/SubcellularLocation.jsx | 275 ++++++++++++++++++ .../src/tests/SubcellularLocation.test.jsx | 78 +++++ 6 files changed, 663 insertions(+), 4 deletions(-) create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_subcellular.py create mode 100644 frontend/src/components/SubcellularLocation.jsx create mode 100644 frontend/src/tests/SubcellularLocation.test.jsx diff --git a/backend/main.py b/backend/main.py index 3f4603f..a93a0bc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def uniprot_search( @app.get("/api/uniprot/entry/{accession}") @limiter.limit("30/minute") async def uniprot_entry(request: Request, accession: str): - """Fetch a full UniProt entry by accession (sequence + metadata).""" + """Fetch a full UniProt entry by accession (sequence + metadata + subcellular locations).""" try: resp = requests.get( f"{UNIPROT_BASE}/{accession}", @@ -440,15 +440,44 @@ async def uniprot_entry(request: Request, accession: str): # Organism organism_name = data.get("organism", {}).get("scientificName", "") - # Function (cc_function) + # Parse comments — collect FUNCTION text and SUBCELLULAR LOCATION entries function_text = "" + subcellular_locations = [] comments = data.get("comments", []) + for c in comments: - if c.get("commentType") == "FUNCTION": + comment_type = c.get("commentType", "") + + if comment_type == "FUNCTION" and not function_text: texts = c.get("texts", []) if texts: function_text = texts[0].get("value", "") - break + + elif comment_type == "SUBCELLULAR LOCATION": + # The note field contains any qualifier text for the whole block + note_obj = c.get("note", {}) + note_texts = [t.get("value", "") for t in note_obj.get("texts", [])] if note_obj else [] + block_note = note_texts[0] if note_texts else "" + + for loc_entry in c.get("subcellularLocations", []): + loc = loc_entry.get("location", {}) + loc_value = loc.get("value", "") + loc_id = loc.get("id", "") + + topology_obj = loc_entry.get("topology") + topology = topology_obj.get("value", "") if topology_obj else "" + + orientation_obj = loc_entry.get("orientation") + orientation = orientation_obj.get("value", "") if orientation_obj else "" + + if loc_value: + subcellular_locations.append({ + "location": loc_value, + "id": loc_id, + "topology": topology, + "orientation": orientation, + "note": block_note, + }) # Sequence seq = data.get("sequence", {}).get("value", "") @@ -461,6 +490,7 @@ async def uniprot_entry(request: Request, accession: str): "geneName": gene_name, "organism": organism_name, "function": function_text, + "subcellularLocations": subcellular_locations, "sequence": seq, "length": length, } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..5c63dcb --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,11 @@ +""" +conftest.py — pytest configuration for the Protly backend test suite. + +Adds the backend directory (parent of this file) to sys.path so that +`import main` works regardless of which directory pytest is invoked from. +""" +import sys +import os + +# Ensure the backend package root is importable +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) diff --git a/backend/tests/test_subcellular.py b/backend/tests/test_subcellular.py new file mode 100644 index 0000000..6d0407d --- /dev/null +++ b/backend/tests/test_subcellular.py @@ -0,0 +1,257 @@ +""" +test_subcellular.py + +Tests for the /api/uniprot/entry/{accession} endpoint which provides +subcellular localization data (and other metadata) for the analysis view. +""" + +import json +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient +import main + +# Bypass JWT auth for testing +main.SUPABASE_JWT_SECRET = "" + +client = TestClient(main.app) + + +# --------------------------------------------------------------------------- +# Helpers — build mock UniProt REST API payloads +# --------------------------------------------------------------------------- + +def _make_uniprot_entry( + accession="P12345", + protein_name="Test Protein", + gene_name="TESTP", + organism="Homo sapiens", + sequence="MACDEFGHIKLMN", + subcellular_locations=None, + function_text="Plays a critical role in testing.", +): + """Build a minimal UniProtKB-style JSON payload.""" + if subcellular_locations is None: + subcellular_locations = [ + { + "location": {"value": "Nucleus", "id": "SL-0191"}, + "topology": None, + "orientation": None, + }, + { + "location": {"value": "Cytoplasm", "id": "SL-0086"}, + "topology": None, + "orientation": None, + }, + ] + + comments = [ + { + "commentType": "FUNCTION", + "texts": [{"value": function_text}], + }, + { + "commentType": "SUBCELLULAR LOCATION", + "subcellularLocations": subcellular_locations, + "note": {"texts": [{"value": "Isoform-specific annotation"}]}, + }, + ] + + return { + "primaryAccession": accession, + "uniProtkbId": f"{gene_name}_HUMAN", + "proteinDescription": { + "recommendedName": { + "fullName": {"value": protein_name} + } + }, + "genes": [{"geneName": {"value": gene_name}}], + "organism": {"scientificName": organism}, + "sequence": {"value": sequence, "length": len(sequence)}, + "comments": comments, + } + + +# --------------------------------------------------------------------------- +# Tests — happy path +# --------------------------------------------------------------------------- + +@patch("main.requests.get") +def test_entry_returns_protein_metadata(mock_get): + """Endpoint should return accession, protein name, gene name, organism.""" + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = _make_uniprot_entry() + mock_get.return_value = mock_response + + resp = client.get("/api/uniprot/entry/P12345") + + assert resp.status_code == 200 + data = resp.json() + assert data["accession"] == "P12345" + assert data["proteinName"] == "Test Protein" + assert data["geneName"] == "TESTP" + assert data["organism"] == "Homo sapiens" + assert data["sequence"] == "MACDEFGHIKLMN" + assert data["length"] == 13 + + +@patch("main.requests.get") +def test_entry_returns_subcellular_locations(mock_get): + """Endpoint must parse SUBCELLULAR LOCATION comment blocks correctly.""" + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = _make_uniprot_entry() + mock_get.return_value = mock_response + + resp = client.get("/api/uniprot/entry/P12345") + data = resp.json() + + locs = data["subcellularLocations"] + assert isinstance(locs, list) + assert len(locs) == 2 + + location_names = [l["location"] for l in locs] + assert "Nucleus" in location_names + assert "Cytoplasm" in location_names + + +@patch("main.requests.get") +def test_entry_subcellular_location_has_expected_fields(mock_get): + """Each location object must include location, id, topology, orientation, note fields.""" + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = _make_uniprot_entry( + subcellular_locations=[ + { + "location": {"value": "Cell membrane", "id": "SL-0039"}, + "topology": {"value": "Single-pass type I membrane protein"}, + "orientation": {"value": "Extracellular side"}, + } + ] + ) + mock_get.return_value = mock_response + + resp = client.get("/api/uniprot/entry/P12345") + data = resp.json() + + loc = data["subcellularLocations"][0] + assert loc["location"] == "Cell membrane" + assert loc["id"] == "SL-0039" + assert loc["topology"] == "Single-pass type I membrane protein" + assert loc["orientation"] == "Extracellular side" + # Block note + assert loc["note"] == "Isoform-specific annotation" + + +@patch("main.requests.get") +def test_entry_returns_function_text(mock_get): + """Endpoint should extract FUNCTION comment text.""" + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = _make_uniprot_entry( + function_text="Involved in signal transduction." + ) + mock_get.return_value = mock_response + + resp = client.get("/api/uniprot/entry/P12345") + data = resp.json() + + assert data["function"] == "Involved in signal transduction." + + +# --------------------------------------------------------------------------- +# Tests — edge cases +# --------------------------------------------------------------------------- + +@patch("main.requests.get") +def test_entry_empty_subcellular_locations(mock_get): + """If the UniProt entry has no SUBCELLULAR LOCATION comment, return empty list.""" + payload = _make_uniprot_entry() + # Strip out the subcellular location comment + payload["comments"] = [c for c in payload["comments"] if c["commentType"] != "SUBCELLULAR LOCATION"] + + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = payload + mock_get.return_value = mock_response + + resp = client.get("/api/uniprot/entry/P12345") + data = resp.json() + + assert data["subcellularLocations"] == [] + + +@patch("main.requests.get") +def test_entry_submission_name_fallback(mock_get): + """If there is no recommendedName, fall back to submissionNames.""" + payload = _make_uniprot_entry() + payload["proteinDescription"] = { + "submissionNames": [{"fullName": {"value": "Unreviewed Protein"}}] + } + + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = payload + mock_get.return_value = mock_response + + resp = client.get("/api/uniprot/entry/P12345") + data = resp.json() + + assert data["proteinName"] == "Unreviewed Protein" + + +@patch("main.requests.get") +def test_entry_no_function_text(mock_get): + """If entry has no FUNCTION comment, function field should be empty string.""" + payload = _make_uniprot_entry() + payload["comments"] = [c for c in payload["comments"] if c["commentType"] != "FUNCTION"] + + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = payload + mock_get.return_value = mock_response + + resp = client.get("/api/uniprot/entry/P12345") + data = resp.json() + + assert data["function"] == "" + + +# --------------------------------------------------------------------------- +# Tests — failure modes +# --------------------------------------------------------------------------- + +@patch("main.requests.get") +def test_entry_upstream_failure_returns_502(mock_get): + """When UniProt is unreachable, the endpoint must return 502 Bad Gateway.""" + import requests as req_lib + + mock_get.side_effect = req_lib.RequestException("Connection refused") + + resp = client.get("/api/uniprot/entry/P00001") + + assert resp.status_code == 502 + assert "UniProt" in resp.json()["detail"] + + +@patch("main.requests.get") +def test_entry_location_with_empty_value_is_skipped(mock_get): + """Location entries with no location value must be silently skipped.""" + payload = _make_uniprot_entry( + subcellular_locations=[ + {"location": {"value": "", "id": ""}, "topology": None, "orientation": None}, + {"location": {"value": "Mitochondrion", "id": "SL-0173"}, "topology": None, "orientation": None}, + ] + ) + + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = payload + mock_get.return_value = mock_response + + resp = client.get("/api/uniprot/entry/P12345") + data = resp.json() + + # Only the valid location should appear + assert len(data["subcellularLocations"]) == 1 + assert data["subcellularLocations"][0]["location"] == "Mitochondrion" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f04a7e8..aa8d081 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,7 @@ import ActionsCard from './components/ActionsCard'; import DiscoveryTable from './components/DiscoveryTable'; import ProteinBio from './components/ProteinBio'; import LabReadiness from './components/LabReadiness'; +import SubcellularLocation from './components/SubcellularLocation'; import SearchPanel from './components/SearchPanel'; import Toast from './components/Toast'; @@ -455,6 +456,13 @@ export default function App() {
+ + {/* ── Subcellular Localization ── */} + +
{ + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = 'var(--shadow-hover)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = ''; + e.currentTarget.style.boxShadow = ''; + }} + > +
+ {icon} + + {item.location} + +
+ + {(item.topology || item.orientation) && ( +
+ {item.topology && ( + + {item.topology} + + )} + {item.orientation && ( + + {item.orientation} + + )} +
+ )} + + {item.note && ( +

+ {item.note.length > 120 ? item.note.slice(0, 120) + '…' : item.note} +

+ )} +
+ ); +} + +export default function SubcellularLocation({ locations, isLoading }) { + if (isLoading) { + return ( +
+
+
+ + 🗺️ + + Subcellular Localization +
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+ ); + } + + if (!locations || locations.length === 0) return null; + + return ( +
+
+
+ + 🗺️ + + Subcellular Localization + + {locations.length} location{locations.length !== 1 ? 's' : ''} + +
+
+ +
+
+ +
+ {/* Cell diagram legend strip */} +
+ 📚 + + Source: UniProtKB · Experimental & predicted annotations + +
+ + {/* Location badges grid */} +
+ {locations.map((item, i) => ( + + ))} +
+ +

+ Subcellular localization describes where within the cell this protein is found. Locations + are annotated from experimental evidence and sequence-based predictions. +

+
+
+ ); +} diff --git a/frontend/src/tests/SubcellularLocation.test.jsx b/frontend/src/tests/SubcellularLocation.test.jsx new file mode 100644 index 0000000..28e17ef --- /dev/null +++ b/frontend/src/tests/SubcellularLocation.test.jsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import SubcellularLocation from '../components/SubcellularLocation'; + +const SAMPLE_LOCATIONS = [ + { location: 'Nucleus', id: 'SL-0191', topology: '', orientation: '', note: '' }, + { location: 'Cytoplasm', id: 'SL-0086', topology: '', orientation: '', note: '' }, + { + location: 'Cell membrane', + id: 'SL-0039', + topology: 'Single-pass type I membrane protein', + orientation: 'Extracellular side', + note: 'Isoform 2 only.', + }, +]; + +describe('SubcellularLocation Component', () => { + it('renders the card title', () => { + render(); + expect(screen.getByText('Subcellular Localization')).toBeInTheDocument(); + }); + + it('renders all location badges', () => { + render(); + expect(screen.getByText('Nucleus')).toBeInTheDocument(); + expect(screen.getByText('Cytoplasm')).toBeInTheDocument(); + expect(screen.getByText('Cell membrane')).toBeInTheDocument(); + }); + + it('shows the correct location count', () => { + render(); + expect(screen.getByText('3 locations')).toBeInTheDocument(); + }); + + it('renders topology and orientation sub-badges when present', () => { + render(); + expect(screen.getByText('Single-pass type I membrane protein')).toBeInTheDocument(); + expect(screen.getByText('Extracellular side')).toBeInTheDocument(); + }); + + it('renders a truncated note when note text is present', () => { + render(); + // Note text is short enough to show fully + expect(screen.getByText('Isoform 2 only.')).toBeInTheDocument(); + }); + + it('renders skeleton placeholders in loading state', () => { + const { container } = render(); + // Should show 3 skeleton divs + const skeletons = container.querySelectorAll('.skeleton'); + expect(skeletons.length).toBe(3); + }); + + it('renders nothing when no locations and not loading', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when locations is null', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('shows singular "location" label for a single entry', () => { + render( + + ); + expect(screen.getByText('1 location')).toBeInTheDocument(); + }); + + it('shows the UniProtKB source attribution line', () => { + render(); + expect(screen.getByText(/UniProtKB/i)).toBeInTheDocument(); + }); +}); From 796d4b691401dad23f5ee6e36f348229852b786d Mon Sep 17 00:00:00 2001 From: jyotsana15 Date: Mon, 30 Mar 2026 21:09:40 +0530 Subject: [PATCH 2/3] fixed formatting --- backend/main.py | 16 +++++++++------- backend/tests/conftest.py | 1 + backend/tests/test_subcellular.py | 19 +++++++------------ 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/backend/main.py b/backend/main.py index a93a0bc..066ad1d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -471,13 +471,15 @@ async def uniprot_entry(request: Request, accession: str): orientation = orientation_obj.get("value", "") if orientation_obj else "" if loc_value: - subcellular_locations.append({ - "location": loc_value, - "id": loc_id, - "topology": topology, - "orientation": orientation, - "note": block_note, - }) + subcellular_locations.append( + { + "location": loc_value, + "id": loc_id, + "topology": topology, + "orientation": orientation, + "note": block_note, + } + ) # Sequence seq = data.get("sequence", {}).get("value", "") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 5c63dcb..12a099c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -4,6 +4,7 @@ Adds the backend directory (parent of this file) to sys.path so that `import main` works regardless of which directory pytest is invoked from. """ + import sys import os diff --git a/backend/tests/test_subcellular.py b/backend/tests/test_subcellular.py index 6d0407d..b5c7fd0 100644 --- a/backend/tests/test_subcellular.py +++ b/backend/tests/test_subcellular.py @@ -5,7 +5,6 @@ subcellular localization data (and other metadata) for the analysis view. """ -import json from unittest.mock import patch, MagicMock from fastapi.testclient import TestClient import main @@ -20,6 +19,7 @@ # Helpers — build mock UniProt REST API payloads # --------------------------------------------------------------------------- + def _make_uniprot_entry( accession="P12345", protein_name="Test Protein", @@ -59,11 +59,7 @@ def _make_uniprot_entry( return { "primaryAccession": accession, "uniProtkbId": f"{gene_name}_HUMAN", - "proteinDescription": { - "recommendedName": { - "fullName": {"value": protein_name} - } - }, + "proteinDescription": {"recommendedName": {"fullName": {"value": protein_name}}}, "genes": [{"geneName": {"value": gene_name}}], "organism": {"scientificName": organism}, "sequence": {"value": sequence, "length": len(sequence)}, @@ -75,6 +71,7 @@ def _make_uniprot_entry( # Tests — happy path # --------------------------------------------------------------------------- + @patch("main.requests.get") def test_entry_returns_protein_metadata(mock_get): """Endpoint should return accession, protein name, gene name, organism.""" @@ -148,9 +145,7 @@ def test_entry_returns_function_text(mock_get): """Endpoint should extract FUNCTION comment text.""" mock_response = MagicMock() mock_response.raise_for_status.return_value = None - mock_response.json.return_value = _make_uniprot_entry( - function_text="Involved in signal transduction." - ) + mock_response.json.return_value = _make_uniprot_entry(function_text="Involved in signal transduction.") mock_get.return_value = mock_response resp = client.get("/api/uniprot/entry/P12345") @@ -163,6 +158,7 @@ def test_entry_returns_function_text(mock_get): # Tests — edge cases # --------------------------------------------------------------------------- + @patch("main.requests.get") def test_entry_empty_subcellular_locations(mock_get): """If the UniProt entry has no SUBCELLULAR LOCATION comment, return empty list.""" @@ -185,9 +181,7 @@ def test_entry_empty_subcellular_locations(mock_get): def test_entry_submission_name_fallback(mock_get): """If there is no recommendedName, fall back to submissionNames.""" payload = _make_uniprot_entry() - payload["proteinDescription"] = { - "submissionNames": [{"fullName": {"value": "Unreviewed Protein"}}] - } + payload["proteinDescription"] = {"submissionNames": [{"fullName": {"value": "Unreviewed Protein"}}]} mock_response = MagicMock() mock_response.raise_for_status.return_value = None @@ -221,6 +215,7 @@ def test_entry_no_function_text(mock_get): # Tests — failure modes # --------------------------------------------------------------------------- + @patch("main.requests.get") def test_entry_upstream_failure_returns_502(mock_get): """When UniProt is unreachable, the endpoint must return 502 Bad Gateway.""" From 207163642c62cbb0b91efae5a93b6147e1077f91 Mon Sep 17 00:00:00 2001 From: jyotsana15 Date: Mon, 30 Mar 2026 21:19:25 +0530 Subject: [PATCH 3/3] fixed all frontend and backend tests --- backend/tests/test_subcellular.py | 2 +- frontend/src/tests/SubcellularLocation.test.jsx | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/backend/tests/test_subcellular.py b/backend/tests/test_subcellular.py index b5c7fd0..851cb3a 100644 --- a/backend/tests/test_subcellular.py +++ b/backend/tests/test_subcellular.py @@ -107,7 +107,7 @@ def test_entry_returns_subcellular_locations(mock_get): assert isinstance(locs, list) assert len(locs) == 2 - location_names = [l["location"] for l in locs] + location_names = [loc["location"] for loc in locs] assert "Nucleus" in location_names assert "Cytoplasm" in location_names diff --git a/frontend/src/tests/SubcellularLocation.test.jsx b/frontend/src/tests/SubcellularLocation.test.jsx index 28e17ef..6e3aade 100644 --- a/frontend/src/tests/SubcellularLocation.test.jsx +++ b/frontend/src/tests/SubcellularLocation.test.jsx @@ -62,12 +62,7 @@ describe('SubcellularLocation Component', () => { }); it('shows singular "location" label for a single entry', () => { - render( - - ); + render(); expect(screen.getByText('1 location')).toBeInTheDocument(); });