From 6eea29f99dcd7f9354a93e27a0813908c4a599c8 Mon Sep 17 00:00:00 2001 From: Carlo Baratto Date: Sat, 10 Oct 2026 09:16:38 +0200 Subject: [PATCH] controllo di contratto fra i QML e il bridge (tests/ui_contract_check.py) Le chiavi lette dalle pagine vengono confrontate con quelle che /summary e /stats restituiscono davvero: la UI non si puo' eseguire senza il kit Silica, quindi un refuso resterebbe invisibile fino al primo build sul telefono. --- README.it.md | 12 ++++++ README.md | 11 ++++++ tests/ui_contract_check.py | 77 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 tests/ui_contract_check.py diff --git a/README.it.md b/README.it.md index 4a175ae..5867515 100644 --- a/README.it.md +++ b/README.it.md @@ -138,6 +138,17 @@ lettura del riassunto e delle statistiche, etichette di stato, corpo dei comandi messaggi d'errore e la formattazione di numeri, importi e mesi della pagina Dati. **La UI Silica si verifica solo sul dispositivo.** +C'è poi un controllo di **contratto fra bridge e interfaccia**: confronta le +chiavi che i QML leggono con quelle che il bridge restituisce davvero (26 da +`/summary`, 15 da `/stats`). Serve il bridge in esecuzione, e nasce dal fatto che +la UI qui non si può eseguire: un refuso in una chiave si vedrebbe sul telefono +come un "—" perenne, senza niente nel log. + +```sh +python3 tests/ui_contract_check.py # bridge su 127.0.0.1:8790 +python3 tests/ui_contract_check.py http://192.168.3.21:8790 +``` + ## Stato - **61/61** verifiche del nucleo, compilazione senza warning @@ -186,6 +197,7 @@ qml/components/InfoRow.qml etichetta a sinistra, valore a destra qml/components/ValueRow.qml titolo e sottotitolo a sinistra, valore grande a destra qml/components/ActionButton.qml pulsante di comando, con conferma opzionale tests/core_test.cpp verifiche del nucleo (headless) +tests/ui_contract_check.py chiavi dei QML contro quelle del bridge (bridge attivo) bridge/aira_bridge.py servizio HTTP/1.1 + JSON verso il cloud (gRPC) bridge/setup_creds.py salva le credenziali Aira (file 600) bridge/aira_probe.py sonda di sola lettura: login + stato impianto diff --git a/README.md b/README.md index 92d2efe..0b78bae 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,16 @@ statistics parsing, mode labels, command payloads, error messages, and the number/currency/month formatting used by the Data page. **The Silica UI can only be verified on a device.** +A second check compares the keys the QML reads with the keys the bridge really +returns (26 from `/summary`, 15 from `/stats`). It needs the bridge running, and +it exists because the UI cannot be run here: a typo in a key would show up as a +permanent “—” on the phone, with nothing in the log. + +```sh +python3 tests/ui_contract_check.py # bridge on 127.0.0.1:8790 +python3 tests/ui_contract_check.py http://192.168.3.21:8790 +``` + ## Status Verified so far: @@ -201,6 +211,7 @@ qml/components/InfoRow.qml label on the left, value on the right qml/components/ValueRow.qml title + subtitle left, big value right qml/components/ActionButton.qml command button, optional confirmation tests/core_test.cpp core checks (headless) +tests/ui_contract_check.py QML keys vs bridge keys (bridge must be running) bridge/ local HTTP bridge -> see bridge/README.md translations/ harbour-aira-it.ts (English is the source language) docs/PIANO.md project plan and decisions (Italian) diff --git a/tests/ui_contract_check.py b/tests/ui_contract_check.py new file mode 100644 index 0000000..bc5610b --- /dev/null +++ b/tests/ui_contract_check.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Controllo di contratto fra il bridge e l'interfaccia di harbour-aira. + +La UI non si puo' eseguire senza il kit Silica (serve il telefono), quindi un +refuso in una chiave letta dal QML resterebbe invisibile fino al primo build: +l'app mostrerebbe "—" per sempre, senza errori. Qui si estraggono le chiavi che +i QML leggono da /summary e da /stats e si confrontano con le risposte vere. + + python3 tests/ui_contract_check.py [http://IP:8790] + +Il token si legge da $AIRA_CONFIG_DIR/aira_bridge.json (default: la +configurazione personale dell'autore), lo stesso file che usa il bridge. +Serve il bridge in esecuzione; non invia nessun comando all'impianto. +""" +from __future__ import annotations + +import json +import os +import pathlib +import re +import sys +import urllib.request + +APP = pathlib.Path(__file__).resolve().parent.parent +QML_DIR = APP / "qml/pages" +CONFIG_DIR = pathlib.Path(os.environ.get( + "AIRA_CONFIG_DIR", str(pathlib.Path.home() / ".hermes/profiles/carlo"))) +BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8790" + + +def chiama(rotta: str) -> dict: + token = json.loads((CONFIG_DIR / "aira_bridge.json").read_text())["token"] + req = urllib.request.Request(BASE + rotta, headers={"Authorization": "Bearer " + token}) + with urllib.request.urlopen(req, timeout=60) as r: + return json.loads(r.read().decode()) + + +def chiavi_qml() -> tuple[set, set]: + """Chiavi di /summary (raw/count) e di /stats (indici) lette dai QML.""" + som, sta = set(), set() + for f in sorted(QML_DIR.glob("*.qml")): + testo = f.read_text() + som |= set(re.findall(r'(?:raw|count)\("([a-z0-9_]+)"\)', testo)) + sta |= set(re.findall(r'(?:stats|mese|risparmi|tariffa)\(\)\["([a-z0-9_]+)"\]', testo)) + sta |= set(re.findall(r'modelData\["([a-z0-9_]+)"\]', testo)) + return som, sta + + +def main() -> int: + som_qml, sta_qml = chiavi_qml() + som = chiama("/summary")["summary"] + sta = chiama("/stats")["stats"] + + # Le statistiche hanno blocchi annidati (month, savings, smart_tariff) e liste + # di oggetti (cop_monthly, monthly_savings, insights): il QML li legge col nome + # semplice, quindi si raccolgono tutte le chiavi interne. + sta_valide = set(sta) + for blocco in ("month", "savings", "smart_tariff"): + sta_valide |= set(sta.get(blocco) or {}) + for lista in ("cop_monthly", "monthly_savings", "insights"): + for voce in (sta.get(lista) or []): + sta_valide |= set(voce) + + mancanti_som = sorted(k for k in som_qml if k not in som) + mancanti_sta = sorted(k for k in sta_qml if k not in sta_valide) + + print(f"chiavi di /summary lette dai QML: {len(som_qml)}") + print(f"chiavi di /stats lette dai QML: {len(sta_qml)}") + print(f"/summary: {len(som)} chiavi, /stats: {len(sta)} chiavi") + print("chiavi di /summary non presenti nel JSON:", mancanti_som or "nessuna") + print("chiavi di /stats non presenti nel JSON: ", mancanti_sta or "nessuna") + print("ESITO:", "CONTRATTO RISPETTATO" if not (mancanti_som or mancanti_sta) else "DISALLINEATO") + return 1 if (mancanti_som or mancanti_sta) else 0 + + +if __name__ == "__main__": + raise SystemExit(main())