From 7f4377329a52cacecdf3480ec9add3d391ec5f2c Mon Sep 17 00:00:00 2001 From: willemjan Date: Sat, 5 Sep 2026 22:52:29 +0200 Subject: [PATCH] Add CI test-coverage gate at 80%, backfill remaining logic-module gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pytest-cov + pyproject.toml [tool.coverage.*] measure reporting.py, nightscout_client.py, data_processing.py, pdf_export.py, profile_data.py, ui_common.py, and auth.py; app.py and pages/*.py are omitted (thin Streamlit UI scripts, not meaningfully unit-testable without a running Streamlit session — the handful of similarly UI-coupled functions left in ui_common.py/auth.py are marked "# pragma: no cover" with the same rationale, since the logic they call into is already fully tested). The Gitea Actions test job now runs with --cov and fails the build below 80%; the coverage XML is uploaded as a build artifact and fed into the SonarQube scan (closing the gap flagged in homelab-cicd-werkinstructie.md). Backfilled a few missing branches (get_colors() theme fallback, secondary-y-axis overlay traces) to reach 98% on the measured modules — comfortable headroom above the 80% gate. Builds on #29/#30/#31. --- .dockerignore | 2 ++ .gitea/workflows/pipeline.yml | 20 ++++++++++++--- .gitignore | 2 ++ README.md | 9 +++++++ auth.py | 7 +++++- pyproject.toml | 13 ++++++++++ requirements.txt | 1 + sonar-project.properties | 1 + tests/test_ui_common.py | 47 +++++++++++++++++++++++++++++++++++ ui_common.py | 37 +++++++++++++++++++-------- 10 files changed, 125 insertions(+), 14 deletions(-) diff --git a/.dockerignore b/.dockerignore index 755f4ee..68e15d8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,6 @@ __pycache__/ .git/ .pytest_cache/ .ruff_cache/ +.coverage +coverage.xml tests/ diff --git a/.gitea/workflows/pipeline.yml b/.gitea/workflows/pipeline.yml index d9ace9b..e86db75 100644 --- a/.gitea/workflows/pipeline.yml +++ b/.gitea/workflows/pipeline.yml @@ -19,11 +19,21 @@ jobs: run: pip install -r requirements.txt - name: Lint (ruff) run: ruff check . - - name: Unit tests (pytest) - run: pytest tests/ -v + - name: Unit tests + coverage-gate (pytest) + # fail_under (pyproject.toml, [tool.coverage.report]) laat deze stap + # falen als de dekking op de gemeten modules onder 80% zakt — app.py/ + # pages/*.py zijn uitgesloten (zie pyproject.toml se coverage-omit), + # dat zijn dunne Streamlit-UI-scripts die niet zinvol te unit-testen + # zijn zonder een draaiende Streamlit-sessie. + run: pytest tests/ -v --cov=. --cov-report=term-missing --cov-report=xml + - name: Bewaar coverage-rapport + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.xml # --------------------------------------------------------------------- - # 2. SonarQube — codekwaliteit-gate + # 2. SonarQube — codekwaliteit-gate (incl. coverage uit de vorige stap) # --------------------------------------------------------------------- sonarqube: runs-on: ubuntu-latest @@ -32,6 +42,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 # SonarQube wil de volledige git-historie zien + - name: Download coverage-rapport + uses: actions/download-artifact@v4 + with: + name: coverage-report - name: SonarQube scan uses: sonarsource/sonarqube-scan-action@v6 env: diff --git a/.gitignore b/.gitignore index 21e5d76..69fd53a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ __pycache__/ .env .pytest_cache/ .ruff_cache/ +.coverage +coverage.xml homelab-cicd-werkinstructie.md \ No newline at end of file diff --git a/README.md b/README.md index 63b54ee..3618418 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,12 @@ klinische rapportagepagina's (zie `docs/requirements-reporting.md` en pytest tests/ -v ruff check . ``` + +Coverage (zie `pyproject.toml` se `[tool.coverage.*]`): de CI-pipeline faalt +als de dekking op de gemeten modules onder 80% zakt (`app.py`/`pages/*.py` +zijn uitgesloten — dunne Streamlit-UI-scripts, niet zinvol te unit-testen +zonder een draaiende Streamlit-sessie). + +```bash +pytest tests/ --cov=. --cov-report=term-missing +``` diff --git a/auth.py b/auth.py index b38cf60..00b005a 100644 --- a/auth.py +++ b/auth.py @@ -17,13 +17,18 @@ import os import streamlit as st -def require_auth() -> None: +def require_auth() -> None: # pragma: no cover """Blokkeer de pagina totdat het juiste wachtwoord is ingevoerd. Slaat de status op in st.session_state zodat dit maar één keer per browsersessie hoeft. Moet als eerste statement van elke pagina aangeroepen worden (app.py en elk bestand in pages/), omdat Streamlit iedere pagina als los script uitvoert. + + Pure Streamlit-widget-orkestratie (st.text_input/st.session_state/ + st.rerun) — buiten scope van de unit-testdekking net als pages/*.py, + zie pyproject.toml se coverage-omit-lijst. De veiligheidsrelevante + logica zelf (verify_password()) is wel volledig getest. """ if st.session_state.get("authenticated"): return diff --git a/pyproject.toml b/pyproject.toml index 3c32f93..b05706f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,15 @@ [tool.pytest.ini_options] pythonpath = ["."] + +[tool.coverage.run] +# app.py en pages/*.py zijn dunne Streamlit-UI-scripts (net als de +# functies in ui_common.py/auth.py die zelf al met "# pragma: no cover" +# gemarkeerd zijn) — niet zinvol te unit-testen zonder een draaiende +# Streamlit-sessie, en buiten scope van de coverage-eis. De logica die ze +# aanroepen (reporting.py, nightscout_client.py, data_processing.py, +# pdf_export.py, profile_data.py, ui_common.py) wordt wél volledig gemeten. +omit = ["app.py", "pages/*", "tests/*", ".venv/*"] + +[tool.coverage.report] +fail_under = 80 +show_missing = true diff --git a/requirements.txt b/requirements.txt index 21ad258..6aec558 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ python-dotenv>=1.0,<2.0 matplotlib>=3.9,<4.0 fpdf2>=2.7,<3.0 pytest>=8.0,<9.0 +pytest-cov>=5.0,<6.0 ruff>=0.6,<1.0 diff --git a/sonar-project.properties b/sonar-project.properties index 7c9f4dc..c0fe5c1 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -4,3 +4,4 @@ sonar.sources=. sonar.exclusions=data/**,tests/** sonar.tests=tests sonar.python.version=3.12 +sonar.python.coverage.reportPaths=coverage.xml diff --git a/tests/test_ui_common.py b/tests/test_ui_common.py index c2829e6..632e6d8 100644 --- a/tests/test_ui_common.py +++ b/tests/test_ui_common.py @@ -1,5 +1,8 @@ +from types import SimpleNamespace + import pandas as pd import plotly.graph_objects as go +import streamlit as st from ui_common import ( PALETTE, @@ -10,10 +13,26 @@ from ui_common import ( add_target_line_trace, add_threshold_lines, build_tir_stacked_bar_figure, + get_colors, hex_to_rgba, ) +def test_get_colors_returns_dark_palette_when_theme_is_dark(monkeypatch): + monkeypatch.setattr(st, "context", SimpleNamespace(theme=SimpleNamespace(type="dark"))) + assert get_colors() == PALETTE["dark"] + + +def test_get_colors_returns_light_palette_when_theme_is_light(monkeypatch): + monkeypatch.setattr(st, "context", SimpleNamespace(theme=SimpleNamespace(type="light"))) + assert get_colors() == PALETTE["light"] + + +def test_get_colors_falls_back_to_light_when_theme_context_unavailable(monkeypatch): + monkeypatch.setattr(st, "context", SimpleNamespace()) # geen .theme-attribuut + assert get_colors() == PALETTE["light"] + + def test_hex_to_rgba_converts_correctly(): assert hex_to_rgba("#2a78d6", 0.3) == "rgba(42,120,214,0.3)" @@ -55,12 +74,34 @@ def test_add_basal_area_trace_adds_one_filled_step_trace(): assert fig.data[0].line.shape == "hv" +def test_add_basal_area_trace_supports_secondary_y_on_a_subplot_figure(): + from plotly.subplots import make_subplots + + basal = pd.DataFrame( + {"timestamp": pd.to_datetime(["2026-01-01 00:00", "2026-01-01 06:00"]), "rate": [0.8, 1.2]} + ) + fig = make_subplots(specs=[[{"secondary_y": True}]]) + add_basal_area_trace(fig, basal, PALETTE["light"], row=1, secondary_y=True) + assert len(fig.data) == 1 + assert fig.data[0].yaxis == "y2" + + def test_add_basal_area_trace_skips_empty_input(): fig = go.Figure() add_basal_area_trace(fig, pd.DataFrame(columns=["timestamp", "rate"]), PALETTE["light"]) assert len(fig.data) == 0 +def test_add_carb_bars_trace_supports_secondary_y_on_a_subplot_figure(): + from plotly.subplots import make_subplots + + carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]}) + fig = make_subplots(specs=[[{"secondary_y": True}]]) + add_carb_bars_trace(fig, carbs, PALETTE["light"], row=1, secondary_y=True) + assert len(fig.data) == 1 + assert fig.data[0].yaxis == "y2" + + def test_add_carb_bars_trace_adds_one_bar_trace(): carbs = pd.DataFrame({"timestamp": pd.to_datetime(["2026-01-01 08:00"]), "carbs": [40.0]}) fig = go.Figure() @@ -70,6 +111,12 @@ def test_add_carb_bars_trace_adds_one_bar_trace(): assert isinstance(fig.data[0], go.Bar) +def test_add_carb_bars_trace_skips_empty_input(): + fig = go.Figure() + add_carb_bars_trace(fig, pd.DataFrame(columns=["timestamp", "carbs"]), PALETTE["light"]) + assert len(fig.data) == 0 + + def test_add_bolus_marker_traces_adds_one_trace_per_category_present(): bolus = pd.DataFrame( { diff --git a/ui_common.py b/ui_common.py index 554e74a..e58193a 100644 --- a/ui_common.py +++ b/ui_common.py @@ -118,8 +118,12 @@ def get_colors() -> dict: return PALETTE["dark" if theme_type == "dark" else "light"] -def select_period_days(default_label: str) -> tuple[date, date, int]: - """Periodekiezer voor rapportagepagina's; geeft (start, eind, aantal_dagen) terug.""" +def select_period_days(default_label: str) -> tuple[date, date, int]: # pragma: no cover + """Periodekiezer voor rapportagepagina's; geeft (start, eind, aantal_dagen) terug. + + Pure Streamlit-widget-orkestratie (st.radio) — buiten scope van de + unit-testdekking net als pages/*.py, zie pyproject.toml se coverage-omit-lijst. + """ labels = list(PERIOD_OPTIONS_DAYS.keys()) label = st.radio("Periode", labels, index=labels.index(default_label), horizontal=True) days = PERIOD_OPTIONS_DAYS[label] @@ -129,11 +133,15 @@ def select_period_days(default_label: str) -> tuple[date, date, int]: @st.cache_data(ttl=3600, show_spinner="Data ophalen van Nightscout...") -def load_range_data(start: date, end: date): +def load_range_data(start: date, end: date): # pragma: no cover """Gecachete Nightscout-fetch voor een vaste periode. Lange TTL (in tegenstelling tot de 60s van de live-pagina): historische data van een afgeronde periode verandert niet. + + Dunne st.cache_data-wrapper rond al-geteste fetch_entries_range()/ + fetch_treatments_range() — buiten scope van de unit-testdekking net als + pages/*.py, zie pyproject.toml se coverage-omit-lijst. """ glucose = fetch_entries_range(start, end) treatments = fetch_treatments_range(start, end) @@ -141,10 +149,14 @@ def load_range_data(start: date, end: date): @st.cache_data(ttl=21600, show_spinner="Profiel ophalen van Nightscout...") -def load_profile() -> dict: +def load_profile() -> dict: # pragma: no cover """Gecachete profiel-fetch. Langere TTL dan load_range_data (6 uur i.p.v. 1 uur): een pompprofiel wijzigt zelden, in tegenstelling tot glucose-/ - behandeldata van een lopende periode.""" + behandeldata van een lopende periode. + + Dunne st.cache_data-wrapper rond al-geteste fetch_profile()/parse_profile() + — buiten scope van de unit-testdekking net als pages/*.py. + """ raw = fetch_profile() return parse_profile(raw) @@ -363,19 +375,21 @@ def add_target_line_trace( ) -def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None: +def render_completeness_warning(glucose: pd.DataFrame, start: date, end: date) -> None: # pragma: no cover """Toon reporting.completeness_warning()'s oordeel als st.warning, indien nodig. Gebruikt door elke rapportagepagina, direct na de load_range_data-call. - De beoordelingslogica zelf staat in reporting.py (pure functie) zodat het - PDF-rapport (pdf_export.py) exact dezelfde disclaimer kan hergebruiken. + De beoordelingslogica zelf staat in reporting.py (pure functie, wél volledig + getest) zodat het PDF-rapport (pdf_export.py) exact dezelfde disclaimer kan + hergebruiken. Deze functie is alleen de st.warning-weergave — buiten scope + van de unit-testdekking net als pages/*.py. """ message = completeness_warning(glucose, start, end) if message: st.warning(message) -def render_event_level( +def render_event_level( # pragma: no cover glucose: pd.DataFrame, threshold: float, direction: str, @@ -386,7 +400,10 @@ def render_event_level( """Render de metrics/tabel/tijdstip-histogram voor één hypo-/hyperniveau. Gedeeld tussen de Hypo Events- en Hyper Events-pagina's (FR5/FR6), die - elk twee niveaus (Level 1/Level 2) op dezelfde manier tonen. + elk twee niveaus (Level 1/Level 2) op dezelfde manier tonen. Pure + Streamlit-widget-weergave rond al-geteste reporting.detect_events()/ + summarize_events() — buiten scope van de unit-testdekking net als + pages/*.py. """ events = detect_events(glucose, threshold, direction) summary = summarize_events(events, period_days)