yakilee Claude Opus 4.6 commited on
Commit
1943883
·
1 Parent(s): 31aaa83

feat: implement 6 Streamlit components + FE services with 68 TDD tests

Browse files

Components (return render specs for testability):
- file_uploader: multi-file PDF/image upload spec
- profile_card: PatientProfile display with prescreen data check
- trial_card: traffic-light eligibility card (green/yellow/red)
- gap_card: gap analysis action card with importance badges
- progress_tracker: 5-state journey progress indicator
- disclaimer_banner: medical disclaimer constant + spec

Services:
- state_manager: session state orchestration, forward-only journey transitions
- parlant_client: synchronous httpx wrapper for Parlant REST API

68 tests pass (37 component + 20 state + 11 client). Ruff clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

app/components/__init__.py ADDED
File without changes
app/components/disclaimer_banner.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Medical disclaimer banner — must appear on every page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ DISCLAIMER_TEXT = (
6
+ "This tool provides information for educational purposes only and does not "
7
+ "constitute medical advice. Always consult your healthcare provider before "
8
+ "making decisions about clinical trial participation."
9
+ )
10
+
11
+
12
+ def render_disclaimer_spec() -> dict:
13
+ """Return a render spec for the disclaimer banner."""
14
+ return {
15
+ "type": "info",
16
+ "text": DISCLAIMER_TEXT,
17
+ }
app/components/file_uploader.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-file PDF/image uploader component for clinical documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ _ACCEPTED_TYPES = ["pdf", "png", "jpg", "jpeg"]
6
+
7
+
8
+ def render_file_uploader_spec() -> dict:
9
+ """Return a render spec for the clinical document uploader widget."""
10
+ return {
11
+ "label": "Upload clinical documents (PDF, images)",
12
+ "accepted_types": list(_ACCEPTED_TYPES),
13
+ "accept_multiple_files": True,
14
+ "help_text": "Upload clinic letters, pathology reports, lab results",
15
+ "widget_key": "clinical_docs_uploader",
16
+ }
17
+
18
+
19
+ def format_file_info(filename: str, size_bytes: int) -> str:
20
+ """Format file name + size for display."""
21
+ if size_bytes >= 1_000_000:
22
+ return f"{filename} ({size_bytes / 1_000_000:.1f} MB)"
23
+ return f"{filename} ({size_bytes / 1_000:.1f} KB)"
app/components/gap_card.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gap analysis action card component."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from trialpath.models import GapItem
6
+
7
+ _IMPORTANCE_COLORS = {
8
+ "high": "red",
9
+ "medium": "orange",
10
+ "low": "grey",
11
+ }
12
+
13
+
14
+ def render_gap_card(
15
+ gap: GapItem,
16
+ affected_trials: list[str] | None = None,
17
+ ) -> dict:
18
+ """Produce a render-spec dict for a single gap item."""
19
+ return {
20
+ "description": gap.description,
21
+ "recommended_action": gap.recommended_action,
22
+ "clinical_importance": gap.clinical_importance,
23
+ "importance_color": _IMPORTANCE_COLORS.get(gap.clinical_importance, "grey"),
24
+ "affected_trials": affected_trials or [],
25
+ }
app/components/profile_card.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PatientProfile display component — produces a render-spec dict."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from trialpath.models import PatientProfile
6
+
7
+
8
+ def render_profile_card(profile: PatientProfile) -> dict:
9
+ """Convert a PatientProfile into a flat dict suitable for UI rendering."""
10
+ return {
11
+ "patient_id": profile.patient_id,
12
+ "demographics": {
13
+ "age": profile.demographics.age,
14
+ "sex": profile.demographics.sex,
15
+ },
16
+ "diagnosis": (
17
+ {
18
+ "primary_condition": profile.diagnosis.primary_condition,
19
+ "histology": profile.diagnosis.histology,
20
+ "stage": profile.diagnosis.stage,
21
+ }
22
+ if profile.diagnosis
23
+ else None
24
+ ),
25
+ "performance_status": (
26
+ {
27
+ "scale": profile.performance_status.scale,
28
+ "value": profile.performance_status.value,
29
+ }
30
+ if profile.performance_status
31
+ else None
32
+ ),
33
+ "biomarkers": [
34
+ {"name": b.name, "result": b.result} for b in profile.biomarkers
35
+ ],
36
+ "treatments": [
37
+ {"drug_name": t.drug_name, "line": t.line} for t in profile.treatments
38
+ ],
39
+ "unknowns": [
40
+ {"field": u.field, "reason": u.reason, "importance": u.importance}
41
+ for u in profile.unknowns
42
+ ],
43
+ "has_minimum_prescreen_data": profile.has_minimum_prescreen_data(),
44
+ }
app/components/progress_tracker.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Journey state progress indicator component."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from app.services.state_manager import JOURNEY_STATES
6
+
7
+ _STEP_LABELS = {
8
+ "INGEST": "Upload Documents",
9
+ "PRESCREEN": "Review Profile",
10
+ "VALIDATE_TRIALS": "Trial Matching",
11
+ "GAP_FOLLOWUP": "Gap Analysis",
12
+ "SUMMARY": "Summary & Export",
13
+ }
14
+
15
+
16
+ def render_progress_tracker(current_state: str) -> dict:
17
+ """Produce a render-spec dict for the journey progress indicator."""
18
+ current_idx = (
19
+ JOURNEY_STATES.index(current_state)
20
+ if current_state in JOURNEY_STATES
21
+ else 0
22
+ )
23
+
24
+ steps = []
25
+ for i, state in enumerate(JOURNEY_STATES):
26
+ if i < current_idx:
27
+ status = "completed"
28
+ elif i == current_idx:
29
+ status = "current"
30
+ else:
31
+ status = "upcoming"
32
+ steps.append({
33
+ "state": state,
34
+ "label": _STEP_LABELS[state],
35
+ "status": status,
36
+ })
37
+
38
+ return {
39
+ "steps": steps,
40
+ "current_index": current_idx,
41
+ }
app/components/trial_card.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Traffic-light eligibility card component for a single trial."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from trialpath.models import EligibilityLedger, TrialCandidate
6
+
7
+
8
+ def render_trial_card(trial: TrialCandidate, ledger: EligibilityLedger) -> dict:
9
+ """Produce a render-spec dict combining trial info and eligibility assessment."""
10
+ return {
11
+ "nct_id": trial.nct_id,
12
+ "title": trial.title,
13
+ "phase": trial.phase,
14
+ "status": trial.status,
15
+ "conditions": trial.conditions,
16
+ "traffic_light": ledger.traffic_light,
17
+ "overall_assessment": ledger.overall_assessment.value,
18
+ "met_count": ledger.met_count,
19
+ "not_met_count": ledger.not_met_count,
20
+ "unknown_count": ledger.unknown_count,
21
+ "criteria": [
22
+ {
23
+ "criterion_id": c.criterion_id,
24
+ "type": c.type,
25
+ "text": c.text,
26
+ "decision": c.decision.value,
27
+ }
28
+ for c in ledger.criteria
29
+ ],
30
+ "gaps": [
31
+ {
32
+ "description": g.description,
33
+ "recommended_action": g.recommended_action,
34
+ "clinical_importance": g.clinical_importance,
35
+ }
36
+ for g in ledger.gaps
37
+ ],
38
+ }
app/services/__init__.py ADDED
File without changes
app/services/parlant_client.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parlant REST API client wrapper for TrialPath Streamlit frontend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import httpx
8
+
9
+ _DEFAULT_BASE_URL = "http://localhost:8800"
10
+ _DEFAULT_TIMEOUT = 65.0 # > long-poll wait_for_data default
11
+
12
+
13
+ class ParlantClient:
14
+ """Synchronous wrapper around the Parlant REST API."""
15
+
16
+ def __init__(
17
+ self,
18
+ base_url: str = _DEFAULT_BASE_URL,
19
+ *,
20
+ transport: Optional[httpx.BaseTransport] = None,
21
+ timeout: float = _DEFAULT_TIMEOUT,
22
+ ) -> None:
23
+ self.base_url = base_url
24
+ self._http = httpx.Client(
25
+ base_url=base_url,
26
+ timeout=timeout,
27
+ transport=transport,
28
+ )
29
+
30
+ # ------ sessions ------
31
+
32
+ def create_session(
33
+ self, agent_id: str, customer_id: Optional[str] = None
34
+ ) -> str:
35
+ """Create a Parlant session and return the session_id."""
36
+ payload: dict = {"agent_id": agent_id}
37
+ if customer_id:
38
+ payload["customer_id"] = customer_id
39
+ resp = self._http.post("/sessions", json=payload)
40
+ resp.raise_for_status()
41
+ return resp.json()["session_id"]
42
+
43
+ def get_session_status(self, session_id: str) -> dict:
44
+ """Fetch session metadata."""
45
+ resp = self._http.get(f"/sessions/{session_id}")
46
+ resp.raise_for_status()
47
+ return resp.json()
48
+
49
+ # ------ events ------
50
+
51
+ def send_message(self, session_id: str, message: str) -> dict:
52
+ """Send a customer message event to a session."""
53
+ resp = self._http.post(
54
+ f"/sessions/{session_id}/events",
55
+ json={"kind": "message", "source": "customer", "message": message},
56
+ )
57
+ resp.raise_for_status()
58
+ return resp.json()
59
+
60
+ def poll_events(
61
+ self,
62
+ session_id: str,
63
+ min_offset: int = 0,
64
+ wait_seconds: int = 60,
65
+ ) -> list[dict]:
66
+ """Poll for new events starting from *min_offset*."""
67
+ resp = self._http.get(
68
+ f"/sessions/{session_id}/events",
69
+ params={"min_offset": min_offset, "wait_for_data": wait_seconds},
70
+ )
71
+ resp.raise_for_status()
72
+ return resp.json()
73
+
74
+ # ------ lifecycle ------
75
+
76
+ def close(self) -> None:
77
+ """Close the underlying HTTP client."""
78
+ self._http.close()
app/services/state_manager.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Session state orchestration for TrialPath Streamlit app."""
2
+
3
+ import streamlit as st
4
+
5
+ JOURNEY_STATES = ["INGEST", "PRESCREEN", "VALIDATE_TRIALS", "GAP_FOLLOWUP", "SUMMARY"]
6
+
7
+ _DEFAULTS = {
8
+ "journey_state": "INGEST",
9
+ "parlant_session_id": None,
10
+ "parlant_agent_id": None,
11
+ "patient_profile": None,
12
+ "uploaded_files": [],
13
+ "search_anchors": None,
14
+ "trial_candidates": [],
15
+ "eligibility_ledger": [],
16
+ "last_event_offset": 0,
17
+ }
18
+
19
+
20
+ def init_session_state() -> None:
21
+ """Initialize all session state variables with defaults.
22
+
23
+ Does not overwrite values that already exist.
24
+ """
25
+ for key, default_value in _DEFAULTS.items():
26
+ if key not in st.session_state:
27
+ st.session_state[key] = default_value
28
+
29
+
30
+ def get_current_journey_state() -> str:
31
+ """Return the current journey state, defaulting to INGEST."""
32
+ return st.session_state.get("journey_state", "INGEST")
33
+
34
+
35
+ def advance_journey(target_state: str) -> None:
36
+ """Advance the journey to *target_state*.
37
+
38
+ Raises ValueError if the target is invalid, the same as current, or backward.
39
+ """
40
+ if target_state not in JOURNEY_STATES:
41
+ raise ValueError(f"Invalid journey state: {target_state}")
42
+
43
+ current = st.session_state.get("journey_state", "INGEST")
44
+ current_idx = JOURNEY_STATES.index(current)
45
+ target_idx = JOURNEY_STATES.index(target_state)
46
+
47
+ if target_idx == current_idx:
48
+ raise ValueError(f"Already at same state: {target_state}")
49
+ if target_idx < current_idx:
50
+ raise ValueError(f"Cannot move backward from {current} to {target_state}")
51
+
52
+ st.session_state["journey_state"] = target_state
53
+
54
+
55
+ def can_advance_to(target_state: str) -> bool:
56
+ """Check whether prerequisites are met to advance to *target_state*."""
57
+ if target_state not in JOURNEY_STATES:
58
+ return False
59
+
60
+ current = st.session_state.get("journey_state", "INGEST")
61
+ current_idx = JOURNEY_STATES.index(current)
62
+ target_idx = JOURNEY_STATES.index(target_state)
63
+
64
+ if target_idx <= current_idx:
65
+ return False
66
+
67
+ # Prerequisites: every state beyond INGEST requires a patient_profile
68
+ if target_idx >= JOURNEY_STATES.index("PRESCREEN"):
69
+ if not st.session_state.get("patient_profile"):
70
+ return False
71
+
72
+ # GAP_FOLLOWUP and beyond require trial_candidates
73
+ if target_idx >= JOURNEY_STATES.index("GAP_FOLLOWUP"):
74
+ if not st.session_state.get("trial_candidates"):
75
+ return False
76
+
77
+ # SUMMARY requires eligibility_ledger
78
+ if target_idx >= JOURNEY_STATES.index("SUMMARY"):
79
+ if not st.session_state.get("eligibility_ledger"):
80
+ return False
81
+
82
+ return True
app/tests/test_components.py ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for app/components/ — Streamlit UI components.
2
+
3
+ Each component is a pure function that takes data and returns a render-spec dict
4
+ (not actual Streamlit widgets) so we can test without a running Streamlit app.
5
+
6
+ Components:
7
+ 1. file_uploader - render_file_uploader_spec
8
+ 2. profile_card - render_profile_card
9
+ 3. trial_card - render_trial_card
10
+ 4. gap_card - render_gap_card
11
+ 5. progress_tracker - render_progress_tracker
12
+ 6. disclaimer_banner - DISCLAIMER_TEXT, render_disclaimer_spec
13
+ """
14
+
15
+ import pytest
16
+
17
+ from trialpath.models import (
18
+ Biomarker,
19
+ CriterionAssessment,
20
+ CriterionDecision,
21
+ Demographics,
22
+ Diagnosis,
23
+ EligibilityLedger,
24
+ GapItem,
25
+ OverallAssessment,
26
+ PatientProfile,
27
+ PerformanceStatus,
28
+ Treatment,
29
+ TrialCandidate,
30
+ UnknownField,
31
+ )
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Fixtures — reusable model instances
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ @pytest.fixture
39
+ def sample_profile():
40
+ return PatientProfile(
41
+ patient_id="P001",
42
+ demographics=Demographics(age=62, sex="Female"),
43
+ diagnosis=Diagnosis(
44
+ primary_condition="Non-Small Cell Lung Cancer",
45
+ histology="adenocarcinoma",
46
+ stage="IIIB",
47
+ ),
48
+ performance_status=PerformanceStatus(scale="ECOG", value=1),
49
+ biomarkers=[
50
+ Biomarker(name="EGFR", result="Exon 19 deletion"),
51
+ Biomarker(name="ALK", result="Negative"),
52
+ Biomarker(name="PD-L1", result="45%"),
53
+ ],
54
+ treatments=[
55
+ Treatment(drug_name="Carboplatin", line=1),
56
+ Treatment(drug_name="Pemetrexed", line=1),
57
+ ],
58
+ unknowns=[
59
+ UnknownField(field="KRAS", reason="Not in records", importance="high"),
60
+ UnknownField(field="Brain MRI", reason="Not available", importance="medium"),
61
+ ],
62
+ )
63
+
64
+
65
+ @pytest.fixture
66
+ def sample_trial():
67
+ return TrialCandidate(
68
+ nct_id="NCT04000001",
69
+ title="KEYNOTE-999: Pembrolizumab + Chemo for NSCLC",
70
+ conditions=["NSCLC"],
71
+ phase="Phase 3",
72
+ status="Recruiting",
73
+ fingerprint_text="Pembrolizumab combination therapy advanced NSCLC",
74
+ )
75
+
76
+
77
+ @pytest.fixture
78
+ def sample_ledger():
79
+ return EligibilityLedger(
80
+ patient_id="P001",
81
+ nct_id="NCT04000001",
82
+ overall_assessment=OverallAssessment.UNCERTAIN,
83
+ criteria=[
84
+ CriterionAssessment(
85
+ criterion_id="inc_1",
86
+ type="inclusion",
87
+ text="Confirmed NSCLC diagnosis",
88
+ decision=CriterionDecision.MET,
89
+ ),
90
+ CriterionAssessment(
91
+ criterion_id="inc_2",
92
+ type="inclusion",
93
+ text="ECOG 0-1",
94
+ decision=CriterionDecision.MET,
95
+ ),
96
+ CriterionAssessment(
97
+ criterion_id="inc_3",
98
+ type="inclusion",
99
+ text="PD-L1 >= 50%",
100
+ decision=CriterionDecision.NOT_MET,
101
+ ),
102
+ CriterionAssessment(
103
+ criterion_id="exc_1",
104
+ type="exclusion",
105
+ text="No prior immunotherapy",
106
+ decision=CriterionDecision.UNKNOWN,
107
+ ),
108
+ ],
109
+ gaps=[
110
+ GapItem(
111
+ description="Brain MRI results needed",
112
+ recommended_action="Upload brain MRI report",
113
+ clinical_importance="high",
114
+ ),
115
+ ],
116
+ )
117
+
118
+
119
+ # ===========================================================================
120
+ # 1. file_uploader
121
+ # ===========================================================================
122
+
123
+
124
+ class TestFileUploader:
125
+ def test_spec_has_accepted_types(self):
126
+ from app.components.file_uploader import render_file_uploader_spec
127
+
128
+ spec = render_file_uploader_spec()
129
+ assert "pdf" in spec["accepted_types"]
130
+ assert "png" in spec["accepted_types"]
131
+ assert "jpg" in spec["accepted_types"]
132
+ assert "jpeg" in spec["accepted_types"]
133
+
134
+ def test_spec_allows_multiple_files(self):
135
+ from app.components.file_uploader import render_file_uploader_spec
136
+
137
+ spec = render_file_uploader_spec()
138
+ assert spec["accept_multiple_files"] is True
139
+
140
+ def test_spec_has_label(self):
141
+ from app.components.file_uploader import render_file_uploader_spec
142
+
143
+ spec = render_file_uploader_spec()
144
+ assert "label" in spec
145
+ assert len(spec["label"]) > 0
146
+
147
+ def test_format_file_info(self):
148
+ from app.components.file_uploader import format_file_info
149
+
150
+ result = format_file_info("report.pdf", 245_000)
151
+ assert "report.pdf" in result
152
+ assert "KB" in result
153
+
154
+ def test_format_file_info_large_file(self):
155
+ from app.components.file_uploader import format_file_info
156
+
157
+ result = format_file_info("scan.pdf", 2_500_000)
158
+ assert "MB" in result
159
+
160
+
161
+ # ===========================================================================
162
+ # 2. profile_card
163
+ # ===========================================================================
164
+
165
+
166
+ class TestProfileCard:
167
+ def test_renders_demographics(self, sample_profile):
168
+ from app.components.profile_card import render_profile_card
169
+
170
+ card = render_profile_card(sample_profile)
171
+ assert card["demographics"]["age"] == 62
172
+ assert card["demographics"]["sex"] == "Female"
173
+
174
+ def test_renders_diagnosis(self, sample_profile):
175
+ from app.components.profile_card import render_profile_card
176
+
177
+ card = render_profile_card(sample_profile)
178
+ assert "NSCLC" in card["diagnosis"]["primary_condition"] or \
179
+ "Non-Small Cell" in card["diagnosis"]["primary_condition"]
180
+ assert card["diagnosis"]["stage"] == "IIIB"
181
+ assert card["diagnosis"]["histology"] == "adenocarcinoma"
182
+
183
+ def test_renders_biomarkers(self, sample_profile):
184
+ from app.components.profile_card import render_profile_card
185
+
186
+ card = render_profile_card(sample_profile)
187
+ assert len(card["biomarkers"]) == 3
188
+ names = [b["name"] for b in card["biomarkers"]]
189
+ assert "EGFR" in names
190
+ assert "PD-L1" in names
191
+
192
+ def test_renders_treatments(self, sample_profile):
193
+ from app.components.profile_card import render_profile_card
194
+
195
+ card = render_profile_card(sample_profile)
196
+ assert len(card["treatments"]) == 2
197
+ assert card["treatments"][0]["drug_name"] == "Carboplatin"
198
+
199
+ def test_renders_unknowns(self, sample_profile):
200
+ from app.components.profile_card import render_profile_card
201
+
202
+ card = render_profile_card(sample_profile)
203
+ assert len(card["unknowns"]) == 2
204
+ fields = [u["field"] for u in card["unknowns"]]
205
+ assert "KRAS" in fields
206
+ assert "Brain MRI" in fields
207
+
208
+ def test_renders_performance_status(self, sample_profile):
209
+ from app.components.profile_card import render_profile_card
210
+
211
+ card = render_profile_card(sample_profile)
212
+ assert card["performance_status"]["scale"] == "ECOG"
213
+ assert card["performance_status"]["value"] == 1
214
+
215
+ def test_handles_none_diagnosis(self):
216
+ from app.components.profile_card import render_profile_card
217
+
218
+ profile = PatientProfile(patient_id="P002")
219
+ card = render_profile_card(profile)
220
+ assert card["diagnosis"] is None
221
+
222
+ def test_handles_empty_biomarkers(self):
223
+ from app.components.profile_card import render_profile_card
224
+
225
+ profile = PatientProfile(patient_id="P003")
226
+ card = render_profile_card(profile)
227
+ assert card["biomarkers"] == []
228
+
229
+ def test_has_minimum_prescreen_flag(self, sample_profile):
230
+ from app.components.profile_card import render_profile_card
231
+
232
+ card = render_profile_card(sample_profile)
233
+ assert card["has_minimum_prescreen_data"] is True
234
+
235
+ def test_has_minimum_prescreen_flag_false(self):
236
+ from app.components.profile_card import render_profile_card
237
+
238
+ profile = PatientProfile(patient_id="P004")
239
+ card = render_profile_card(profile)
240
+ assert card["has_minimum_prescreen_data"] is False
241
+
242
+
243
+ # ===========================================================================
244
+ # 3. trial_card
245
+ # ===========================================================================
246
+
247
+
248
+ class TestTrialCard:
249
+ def test_renders_basic_info(self, sample_trial, sample_ledger):
250
+ from app.components.trial_card import render_trial_card
251
+
252
+ card = render_trial_card(sample_trial, sample_ledger)
253
+ assert card["nct_id"] == "NCT04000001"
254
+ assert "KEYNOTE" in card["title"]
255
+ assert card["phase"] == "Phase 3"
256
+ assert card["status"] == "Recruiting"
257
+
258
+ def test_renders_traffic_light(self, sample_trial, sample_ledger):
259
+ from app.components.trial_card import render_trial_card
260
+
261
+ card = render_trial_card(sample_trial, sample_ledger)
262
+ assert card["traffic_light"] == "yellow"
263
+
264
+ def test_renders_criteria_counts(self, sample_trial, sample_ledger):
265
+ from app.components.trial_card import render_trial_card
266
+
267
+ card = render_trial_card(sample_trial, sample_ledger)
268
+ assert card["met_count"] == 2
269
+ assert card["not_met_count"] == 1
270
+ assert card["unknown_count"] == 1
271
+
272
+ def test_renders_criteria_list(self, sample_trial, sample_ledger):
273
+ from app.components.trial_card import render_trial_card
274
+
275
+ card = render_trial_card(sample_trial, sample_ledger)
276
+ assert len(card["criteria"]) == 4
277
+ first = card["criteria"][0]
278
+ assert "criterion_id" in first
279
+ assert "text" in first
280
+ assert "decision" in first
281
+
282
+ def test_renders_overall_assessment(self, sample_trial, sample_ledger):
283
+ from app.components.trial_card import render_trial_card
284
+
285
+ card = render_trial_card(sample_trial, sample_ledger)
286
+ assert card["overall_assessment"] == "uncertain"
287
+
288
+ def test_green_traffic_light(self, sample_trial):
289
+ from app.components.trial_card import render_trial_card
290
+
291
+ ledger = EligibilityLedger(
292
+ patient_id="P001",
293
+ nct_id="NCT04000001",
294
+ overall_assessment=OverallAssessment.LIKELY_ELIGIBLE,
295
+ criteria=[
296
+ CriterionAssessment(
297
+ criterion_id="inc_1", type="inclusion",
298
+ text="Has NSCLC", decision=CriterionDecision.MET,
299
+ ),
300
+ ],
301
+ )
302
+ card = render_trial_card(sample_trial, ledger)
303
+ assert card["traffic_light"] == "green"
304
+
305
+ def test_red_traffic_light(self, sample_trial):
306
+ from app.components.trial_card import render_trial_card
307
+
308
+ ledger = EligibilityLedger(
309
+ patient_id="P001",
310
+ nct_id="NCT04000001",
311
+ overall_assessment=OverallAssessment.LIKELY_INELIGIBLE,
312
+ criteria=[
313
+ CriterionAssessment(
314
+ criterion_id="inc_1", type="inclusion",
315
+ text="Has NSCLC", decision=CriterionDecision.NOT_MET,
316
+ ),
317
+ ],
318
+ )
319
+ card = render_trial_card(sample_trial, ledger)
320
+ assert card["traffic_light"] == "red"
321
+
322
+ def test_gaps_included(self, sample_trial, sample_ledger):
323
+ from app.components.trial_card import render_trial_card
324
+
325
+ card = render_trial_card(sample_trial, sample_ledger)
326
+ assert len(card["gaps"]) == 1
327
+ assert card["gaps"][0]["description"] == "Brain MRI results needed"
328
+
329
+
330
+ # ===========================================================================
331
+ # 4. gap_card
332
+ # ===========================================================================
333
+
334
+
335
+ class TestGapCard:
336
+ def test_renders_single_gap(self):
337
+ from app.components.gap_card import render_gap_card
338
+
339
+ gap = GapItem(
340
+ description="Brain MRI results needed",
341
+ recommended_action="Upload brain MRI report",
342
+ clinical_importance="high",
343
+ )
344
+ card = render_gap_card(gap, affected_trials=["NCT04000001", "NCT04000003"])
345
+ assert card["description"] == "Brain MRI results needed"
346
+ assert card["recommended_action"] == "Upload brain MRI report"
347
+ assert card["clinical_importance"] == "high"
348
+ assert len(card["affected_trials"]) == 2
349
+
350
+ def test_renders_gap_without_affected_trials(self):
351
+ from app.components.gap_card import render_gap_card
352
+
353
+ gap = GapItem(
354
+ description="KRAS mutation status",
355
+ recommended_action="Request test from oncologist",
356
+ clinical_importance="medium",
357
+ )
358
+ card = render_gap_card(gap)
359
+ assert card["affected_trials"] == []
360
+
361
+ def test_importance_badge(self):
362
+ from app.components.gap_card import render_gap_card
363
+
364
+ gap = GapItem(
365
+ description="Test gap",
366
+ recommended_action="Do something",
367
+ clinical_importance="high",
368
+ )
369
+ card = render_gap_card(gap)
370
+ assert card["importance_color"] == "red"
371
+
372
+ def test_importance_badge_medium(self):
373
+ from app.components.gap_card import render_gap_card
374
+
375
+ gap = GapItem(
376
+ description="Test gap",
377
+ recommended_action="Do something",
378
+ clinical_importance="medium",
379
+ )
380
+ card = render_gap_card(gap)
381
+ assert card["importance_color"] == "orange"
382
+
383
+ def test_importance_badge_low(self):
384
+ from app.components.gap_card import render_gap_card
385
+
386
+ gap = GapItem(
387
+ description="Test gap",
388
+ recommended_action="Do something",
389
+ clinical_importance="low",
390
+ )
391
+ card = render_gap_card(gap)
392
+ assert card["importance_color"] == "grey"
393
+
394
+
395
+ # ===========================================================================
396
+ # 5. progress_tracker
397
+ # ===========================================================================
398
+
399
+
400
+ class TestProgressTracker:
401
+ def test_renders_all_states(self):
402
+ from app.components.progress_tracker import render_progress_tracker
403
+
404
+ spec = render_progress_tracker("INGEST")
405
+ assert len(spec["steps"]) == 5
406
+
407
+ def test_ingest_is_current(self):
408
+ from app.components.progress_tracker import render_progress_tracker
409
+
410
+ spec = render_progress_tracker("INGEST")
411
+ steps = spec["steps"]
412
+ assert steps[0]["status"] == "current"
413
+ assert steps[1]["status"] == "upcoming"
414
+
415
+ def test_middle_state(self):
416
+ from app.components.progress_tracker import render_progress_tracker
417
+
418
+ spec = render_progress_tracker("VALIDATE_TRIALS")
419
+ steps = spec["steps"]
420
+ assert steps[0]["status"] == "completed"
421
+ assert steps[1]["status"] == "completed"
422
+ assert steps[2]["status"] == "current"
423
+ assert steps[3]["status"] == "upcoming"
424
+ assert steps[4]["status"] == "upcoming"
425
+
426
+ def test_summary_state(self):
427
+ from app.components.progress_tracker import render_progress_tracker
428
+
429
+ spec = render_progress_tracker("SUMMARY")
430
+ steps = spec["steps"]
431
+ for i in range(4):
432
+ assert steps[i]["status"] == "completed"
433
+ assert steps[4]["status"] == "current"
434
+
435
+ def test_step_labels(self):
436
+ from app.components.progress_tracker import render_progress_tracker
437
+
438
+ spec = render_progress_tracker("INGEST")
439
+ labels = [s["label"] for s in spec["steps"]]
440
+ assert "Upload" in labels[0]
441
+ assert "Summary" in labels[4] or "Export" in labels[4]
442
+
443
+ def test_current_index(self):
444
+ from app.components.progress_tracker import render_progress_tracker
445
+
446
+ spec = render_progress_tracker("GAP_FOLLOWUP")
447
+ assert spec["current_index"] == 3
448
+
449
+
450
+ # ===========================================================================
451
+ # 6. disclaimer_banner
452
+ # ===========================================================================
453
+
454
+
455
+ class TestDisclaimerBanner:
456
+ def test_disclaimer_text_content(self):
457
+ from app.components.disclaimer_banner import DISCLAIMER_TEXT
458
+
459
+ text_lower = DISCLAIMER_TEXT.lower()
460
+ assert "information" in text_lower or "educational" in text_lower
461
+ assert "medical advice" in text_lower
462
+
463
+ def test_disclaimer_spec(self):
464
+ from app.components.disclaimer_banner import render_disclaimer_spec
465
+
466
+ spec = render_disclaimer_spec()
467
+ assert spec["type"] == "info"
468
+ assert len(spec["text"]) > 0
469
+
470
+ def test_disclaimer_not_empty(self):
471
+ from app.components.disclaimer_banner import DISCLAIMER_TEXT
472
+
473
+ assert len(DISCLAIMER_TEXT) > 20