Rows and banners are built once with their Dutch beside them (messages.Bi) and status_payload() picks one by the page's language. Settings notes and the wizard's checks carry a Dutch twin (Note.nl) that /config/validate picks by the posted language. The peak forecast label drops "(W)" in EN and NL. Co-Authored-By: Claude <noreply@anthropic.com>
579 lines
25 KiB
Python
579 lines
25 KiB
Python
"""UX-46: the Dutch pages show Dutch status rows, notes and wizard lines.
|
|
`python3 -B test_ux46.py`
|
|
|
|
Before this, Home's status rows, the settings page's notes and the setup
|
|
wizard's checks were built in English and sent as they were, so the NL pages
|
|
showed English under Dutch titles (SAFETY-118 I-1, UX-44 I-2, UX-45 I-3).
|
|
|
|
1. Rows: every row in `messages.status_samples()` carries a Dutch twin
|
|
(`messages.Bi`), the Dutch follows STYLE.md, and the Home page's status
|
|
payload gives it for lang=nl and the English text for everything else.
|
|
2. Notes: every `_advise()` call in app/validate.py gives a Dutch twin, the
|
|
notes the validator really builds carry it, and /config/validate sends the
|
|
page's language, for the settings notes and for the wizard's check list.
|
|
3. The setting labels the Dutch notes quote are the labels in nl.yaml, and the
|
|
peak forecast label has no "(W)", since any power unit is read.
|
|
4. The rules above catch spoiled input.
|
|
"""
|
|
|
|
import ast
|
|
import asyncio
|
|
import copy
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from aiohttp import ClientSession, web as aioweb
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE / "tools"))
|
|
logging.disable(logging.WARNING)
|
|
|
|
import translations_lint as TL # noqa: E402
|
|
import ux13_demo as D # noqa: E402
|
|
from style_rules import sentences, text_problems, words # noqa: E402
|
|
from app import messages, uimodel as U, uitext, web # noqa: E402
|
|
from app.main import Controller # noqa: E402
|
|
from app.validate import (Note, note_text, setup_checks, # noqa: E402
|
|
config_notes)
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
# STYLE.md for a Dutch row or note: the same length and shape rules, plus the
|
|
# Dutch words that test_ui_text.py bans for the same things. A quoted setting
|
|
# label may keep its own words ("Setpoint-entiteit" is the label in nl.yaml).
|
|
JARGON_NL = re.compile(
|
|
r"\b(?:setpoints?|setpunt\w*|SoC|laadtoestand|BMS|PV|failsafe|regelaar|"
|
|
r"regellus|controller|latch|ontlad\w*|payload|endpoint|JSON|API|"
|
|
r"time-?out|boolean|config|kwartuur\w*|commando\w*|geparkeerd|ESP32|"
|
|
r"master)\b", re.I)
|
|
# English function words that never belong in a Dutch sentence.
|
|
ENGLISH = re.compile(r"\b(?:the|not|and|for|with|from|reads|check|so|no|set|"
|
|
r"keeps|stays|has|have|ran|ago|seconds?|minutes?)\b", re.I)
|
|
QUOTED = re.compile(r"“[^”]*”")
|
|
|
|
|
|
def dutch_problems(text):
|
|
bare = re.sub(r"\b[a-z_]+\.[a-z0-9_]+\b", "X", QUOTED.sub("X", text))
|
|
out = []
|
|
if JARGON_NL.search(bare):
|
|
out.append("jargon: " + JARGON_NL.search(bare).group(0))
|
|
names = re.sub(r"\binverter-[a-z0-9]+\b|\b[a-z_]+\.[a-z0-9_]+\b", "X",
|
|
bare)
|
|
eng = ENGLISH.search(names)
|
|
if eng:
|
|
out.append("English word: " + eng.group(0))
|
|
return out
|
|
|
|
|
|
def row_problems_nl(colour, text):
|
|
n_s, n_w = sentences(text), len(words(text))
|
|
out = []
|
|
if colour == messages.RED:
|
|
if n_s > 3 or n_w > 30:
|
|
out.append(f"red row over 3 sentences / 30 words ({n_s}/{n_w})")
|
|
elif n_s > 1 or n_w > 15:
|
|
out.append(f"row over 1 sentence / 15 words ({n_s}/{n_w})")
|
|
if ";" in text:
|
|
out.append("semicolon")
|
|
return out + text_problems(text, keys=False, entities=False) \
|
|
+ dutch_problems(text)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("1. every status row has a Dutch twin, and the page picks one")
|
|
# --------------------------------------------------------------------------- #
|
|
ROWS = [(fn, c, t) for fn, c, t in messages.status_samples() if c is not None]
|
|
check(f"there are rows to grade ({len(ROWS)})", len(ROWS) > 80)
|
|
_none = [(fn, str(t)) for fn, c, t in ROWS
|
|
if not (isinstance(t, messages.Bi) and t.nl)]
|
|
check(f"every row has a Dutch text ({_none})", not _none)
|
|
_same = [(fn, str(t)) for fn, c, t in ROWS
|
|
if isinstance(t, messages.Bi) and t.nl == str(t)]
|
|
check(f"no Dutch row is the English one ({_same})", not _same)
|
|
_bad = [(fn, p) for fn, c, t in ROWS if isinstance(t, messages.Bi) and t.nl
|
|
for p in row_problems_nl(c, t.nl)]
|
|
for x in _bad:
|
|
print(f" STYLE {x}")
|
|
check(f"every Dutch row follows STYLE.md ({len(_bad)} problems)", not _bad)
|
|
BANNERS = [(fn, t) for fn, c, t in messages.status_samples() if c is None]
|
|
_nob = [(fn, str(t)) for fn, t in BANNERS
|
|
if not (isinstance(t, messages.Bi) and t.nl and t.nl != str(t))]
|
|
check(f"every banner has a Dutch text ({_nob})", BANNERS and not _nob)
|
|
_bb = [(fn, t.nl) for fn, t in BANNERS if isinstance(t, messages.Bi) and t.nl
|
|
and (len(words(t.nl)) > 12 or ";" in t.nl or dutch_problems(t.nl)
|
|
or text_problems(t.nl, keys=False, entities=False))]
|
|
check(f"every Dutch banner is at most 12 words and follows STYLE.md ({_bb})",
|
|
not _bb)
|
|
_cat = {k for k, v in vars(messages).items()
|
|
if callable(v) and k.startswith("row_")}
|
|
check("the samples cover every row_ function, so none can skip the check",
|
|
_cat <= {fn for fn, _c, _t in ROWS})
|
|
_lat = {"row_direction_trip": "herstart heft dit niet op",
|
|
"row_site_direction_trip": "regeling starten heft dit op",
|
|
"row_circulating_trip": "regeling starten heft dit op",
|
|
"row_not_delivering": "stoppen en starten heft dit op"}
|
|
_unclear = [fn for fn, _c, t in ROWS if fn in _lat and _lat[fn] not in t.nl]
|
|
check(f"a Dutch latch row says how it clears too ({_unclear})", not _unclear)
|
|
check("pick() gives the English for en, the Dutch for nl, and leaves other "
|
|
"text alone",
|
|
messages.pick(messages.row_estop_fitted(), "en") == "Emergency stop: fitted"
|
|
and type(messages.pick(messages.row_estop_fitted(), "en")) is str
|
|
and messages.pick(messages.row_estop_fitted(), "nl") == "Noodstop: geplaatst"
|
|
and messages.pick("Plain", "nl") == "Plain"
|
|
and messages.pick(messages.Bi("A"), "nl") == "A")
|
|
check("a Bi survives copy and pickle, and JSON",
|
|
copy.deepcopy(messages.row_estop_fitted()).nl == "Noodstop: geplaatst"
|
|
and json.dumps(messages.row_estop_fitted()) == '"Emergency stop: fitted"')
|
|
|
|
|
|
# --- the page: a real Controller -------------------------------------------- #
|
|
class _Store:
|
|
def __init__(self):
|
|
self.data = {}
|
|
|
|
def set(self, k, v):
|
|
self.data[k] = v
|
|
|
|
def get(self, k, d=None):
|
|
return self.data.get(k, d)
|
|
|
|
def set_time(self, k, v):
|
|
self.data[k] = v.isoformat()
|
|
|
|
def get_time(self, k):
|
|
v = self.data.get(k)
|
|
return None if v is None else datetime.fromisoformat(v)
|
|
|
|
|
|
class _Pub:
|
|
enabled = False
|
|
|
|
def publish(self, _v):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
class _Hass:
|
|
session = None
|
|
token = ""
|
|
|
|
async def limits(self, _e):
|
|
return (-5000.0, 5000.0, 1.0)
|
|
|
|
async def number(self, _e, invert=False):
|
|
return None
|
|
|
|
async def state(self, _e):
|
|
return None
|
|
|
|
async def set_number(self, _e, _v):
|
|
return True
|
|
|
|
async def call(self, *_a):
|
|
return True
|
|
|
|
async def states(self):
|
|
return []
|
|
|
|
|
|
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
|
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
|
"maintenance_enabled": False, "peak_forecast_entity": "sensor.fc"}
|
|
TWO = dict(ONE, inverters=[
|
|
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
|
|
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500},
|
|
{"name": "Battery B", "setpoint_entity": "", "soc_entity":
|
|
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500}])
|
|
|
|
|
|
def make(opts, soc=(62.0, 35.0), batt=(-2500.0, -300.0)):
|
|
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
|
c.auto = True
|
|
for i, inv in enumerate(c.inverters):
|
|
inv.soc, inv.batt = soc[i], batt[i]
|
|
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
|
c.grid = 10.0
|
|
return c
|
|
|
|
|
|
def attention(payload, rid):
|
|
return next((a for a in payload["attention"] if a["id"] == rid), None)
|
|
|
|
|
|
c = make(ONE, soc=(62.0,), batt=(960.0,))
|
|
c._peak_fc_fault = "kWh"
|
|
s_en, s_nl = c.status_payload(), c.status_payload(lang="nl")
|
|
_en = next(r for r in s_en["checks"] if r.get("id") == "peak_forecast")
|
|
_nl = next(r for r in s_nl["checks"] if r.get("id") == "peak_forecast")
|
|
check("Home, English: the peak forecast row is the English sentence",
|
|
_en["text"] == messages.peak_forecast_unit("kWh")
|
|
and attention(s_en, "peak_forecast")["body"]
|
|
== messages.peak_forecast_unit("kWh"))
|
|
check("Home, Dutch: the row, the Needs-attention entry and the title are "
|
|
"Dutch",
|
|
_nl["text"] == messages.peak_forecast_unit("kWh", "nl")
|
|
and attention(s_nl, "peak_forecast")["body"]
|
|
== messages.peak_forecast_unit("kWh", "nl")
|
|
and attention(s_nl, "peak_forecast")["title"]
|
|
== uitext.words("nl")["home_words"]["attn"]["peak_forecast"])
|
|
c._peak_fc_fault = ""
|
|
_nl0 = next(r for r in c.status_payload(lang="nl")["checks"]
|
|
if r.get("id") == "peak_forecast")
|
|
check("...and with no unit at all, the Dutch is the 'no unit' sentence",
|
|
_nl0["text"] == messages.peak_forecast_unit("", "nl")
|
|
and messages.peak_forecast_unit("", "nl")
|
|
!= messages.peak_forecast_unit("kWh", "nl"))
|
|
check("a language that is not Dutch gets the English, and the default call "
|
|
"is unchanged",
|
|
c.status_payload(lang="fr")["checks"] == c.status_payload()["checks"]
|
|
and all(type(r["text"]) is str for r in c.status_payload()["checks"]))
|
|
|
|
# A site with faults of many kinds: every row it shows is Dutch on the NL page.
|
|
SCEN = {}
|
|
SCEN["two, one without a setpoint, meter stale"] = make(TWO)
|
|
SCEN["one, nothing read"] = make(ONE, soc=(None,), batt=(None,))
|
|
SCEN["one, no forecast set"] = make(dict(ONE, peak_forecast_entity=""))
|
|
SCEN["one, shadow, estop fitted"] = make(dict(ONE, shadow_mode="on",
|
|
estop_fitted=True))
|
|
SCEN["one, solar sources"] = make(dict(ONE, pv_entity="sensor.pv"))
|
|
SCEN["two, solar list"] = make(dict(TWO, pv_entities=["sensor.a", "sensor.b"]))
|
|
SCEN["one, peak forecast unit fault"] = c
|
|
SCEN["one, control stopped"] = make(ONE)
|
|
SCEN["one, control stopped"].auto = False
|
|
_English, _rows_seen = [], 0
|
|
for name, ctl in SCEN.items():
|
|
raw = ctl.checks()
|
|
en = ctl.status_payload()["checks"]
|
|
nl = ctl.status_payload(lang="nl")["checks"]
|
|
check(f"[{name}] {len(raw)} rows: same rows in both languages",
|
|
len(raw) == len(en) == len(nl))
|
|
for r, e, n in zip(raw, en, nl):
|
|
_rows_seen += 1
|
|
if not (isinstance(r["text"], messages.Bi) and r["text"].nl):
|
|
_English.append((name, r.get("id"), str(r["text"])))
|
|
elif e["text"] != str(r["text"]) or n["text"] != r["text"].nl:
|
|
_English.append((name, r.get("id"), "page did not pick"))
|
|
_banners = {n: (ctl.status_payload()["banner"],
|
|
ctl.status_payload(lang="nl")["banner"])
|
|
for n, ctl in SCEN.items()}
|
|
print(f" banners: {_banners}")
|
|
check("the banner at the top of Home is Dutch on the Dutch page, English on "
|
|
"the English one", all(e != n and not dutch_problems(n)
|
|
for e, n in _banners.values()))
|
|
check(f"{_rows_seen} rows over {len(SCEN)} sites: none shows English on the "
|
|
f"Dutch page when it has no Dutch text ({_English})", not _English)
|
|
check("the row's details list in /status is picked the same way",
|
|
all(isinstance(v, str) and not isinstance(v, messages.Bi)
|
|
for ctl in SCEN.values()
|
|
for _k, v in ctl.status_payload(lang="nl")["rows"]))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n2. every settings note and wizard line has a Dutch twin")
|
|
# --------------------------------------------------------------------------- #
|
|
tree = ast.parse((HERE / "app" / "validate.py").read_text(encoding="utf-8"))
|
|
_calls = [n for n in ast.walk(tree) if isinstance(n, ast.Call)
|
|
and ast.unparse(n.func) == "_advise"]
|
|
_bare = [n.lineno for n in _calls if not any(k.arg == "nl" for k in n.keywords)]
|
|
check(f"every _advise() call in validate.py gives `nl` ({len(_calls)} calls, "
|
|
f"without: {_bare})", len(_calls) >= 30 and not _bare)
|
|
|
|
OPTS = dict(D.defaults(D.CONFIG), setpoint_entity="number.sp",
|
|
meter_entity="sensor.grid", soc_entity="sensor.soc",
|
|
batt_entity="sensor.batt", pv_entity="sensor.pv",
|
|
peak_forecast_entity="sensor.fc")
|
|
|
|
|
|
def ent(state, unit=None):
|
|
return {"state": state, "unit": unit}
|
|
|
|
|
|
GOOD = {"number.sp": ent("0"), "sensor.grid": ent("402", "W"),
|
|
"sensor.soc": ent("62", "%"), "sensor.batt": ent("-800", "W"),
|
|
"sensor.pv": ent("-1200", "W"), "sensor.fc": ent("820", "kWh")}
|
|
NOTES = []
|
|
NOTES += setup_checks(OPTS, entities=GOOD, running=None)
|
|
NOTES += setup_checks(OPTS, entities=None)
|
|
NOTES += setup_checks(dict(OPTS, meter_invert=True, pv_invert_entities=["sensor.pv"]),
|
|
entities=dict(GOOD, **{"sensor.batt": ent("0", "W")}))
|
|
NOTES += setup_checks(dict(OPTS, soc_entity="sensor.x", batt_entity="sensor.y",
|
|
setpoint_entity="number.typo"),
|
|
entities=GOOD)
|
|
NOTES += setup_checks(dict(OPTS, meter_source="homewizard_local"),
|
|
entities=GOOD, running=None)
|
|
NOTES += setup_checks(dict(OPTS, meter_source="homewizard_local",
|
|
price_provider="generic_dayahead"),
|
|
entities=GOOD, running=dict(OPTS, meter_source="homewizard_local",
|
|
price_provider="generic_dayahead"),
|
|
meter_now_w=-350.0, prices_known=18)
|
|
NOTES += setup_checks(dict(OPTS, price_provider="generic_dayahead"), entities=GOOD,
|
|
running=None)
|
|
NOTES += setup_checks(dict(OPTS, price_provider="off", static_profile=""),
|
|
entities=GOOD)
|
|
NOTES += setup_checks(dict(OPTS, price_provider="off",
|
|
static_profile='{"periods":[]}'), entities=GOOD)
|
|
NOTES += setup_checks(dict(OPTS, batt_entity="sensor.batt2"),
|
|
entities=dict(GOOD, **{"sensor.batt2": ent("unavailable")}))
|
|
NOTES += setup_checks(dict(OPTS, soc_entity="sensor.soc2"),
|
|
entities=dict(GOOD, **{"sensor.soc2": ent("unavailable")}))
|
|
NOTES += setup_checks(dict(OPTS, meter_entity="sensor.m2"),
|
|
entities=dict(GOOD, **{"sensor.m2": ent("unavailable")}))
|
|
_TWO = dict(OPTS, inverters=[
|
|
{"name": "a", "setpoint_entity": "number.sp", "soc_entity": "sensor.soc",
|
|
"batt_entity": "sensor.batt", "max_w": 2000, "bms_soc_floor": 10},
|
|
{"name": "b", "setpoint_entity": "number.b", "max_w": 2000,
|
|
"bms_soc_floor": 10}])
|
|
NOTES += setup_checks(_TWO, entities=dict(GOOD, **{"sensor.batt": ent("-0.5", "kW")}))
|
|
NOTES += setup_checks(dict(OPTS, meter_source="ha_signed",
|
|
p1_net_entity="sensor.net"),
|
|
entities=dict(GOOD, **{"sensor.net": ent("-3.1", "kW")}))
|
|
NOTES += config_notes(dict(OPTS, integrator_max_w=9000, max_w=2000,
|
|
peak_cap_w=2000), entities=GOOD, month_peak_w=3000)
|
|
NOTES += config_notes(dict(OPTS, pv_forecast_entity="sensor.f1",
|
|
pv_forecast_tomorrow_entity="sensor.f2",
|
|
peak_forecast_entity="sensor.fc2"),
|
|
entities=dict(GOOD, **{"sensor.f1": ent("5", "W"),
|
|
"sensor.f2": ent("6", "kWh"),
|
|
"sensor.fc2": ent("5")}))
|
|
NOTES += config_notes(dict(OPTS, meter_entity="sensor.gone"), entities=GOOD)
|
|
NOTES += config_notes(dict(OPTS, pv_forecast_tomorrow_entity="sensor.f2"),
|
|
entities=dict(GOOD, **{"sensor.f2": ent("6", "kWh")}))
|
|
NOTES += config_notes(dict(OPTS, batt_entity="sensor.kw"),
|
|
entities=dict(GOOD, **{"sensor.kw": ent("1", "kW")}))
|
|
print(f" {len(NOTES)} notes built")
|
|
_nonl = [n.text for n in NOTES if not n.nl]
|
|
check(f"every note the validator builds has a Dutch twin ({_nonl})",
|
|
NOTES and not _nonl)
|
|
_same = [n.text for n in NOTES if n.nl == n.text]
|
|
check(f"...which is not the English ({_same})", not _same)
|
|
_bad = []
|
|
for n in NOTES:
|
|
nw, ns = len(words(n.nl)), sentences(n.nl)
|
|
p = text_problems(n.nl, keys=True, entities=True) + dutch_problems(n.nl)
|
|
if nw > 25 and not n.text.startswith(("The 15-minute", "Home Assistant")):
|
|
p.append(f"over 25 words ({nw})")
|
|
if ns > 2:
|
|
p.append(f"over 2 sentences ({ns})")
|
|
if ";" in n.nl:
|
|
p.append("semicolon")
|
|
if len(words(n.nl)) > len(words(n.text)) + 6:
|
|
p.append("much longer than the English")
|
|
_bad += [(n.nl, x) for x in p]
|
|
for x in _bad:
|
|
print(f" STYLE {x}")
|
|
check(f"every Dutch note follows STYLE.md ({len(_bad)} problems)", not _bad)
|
|
_l = {t: [n for n in NOTES if n.text.startswith(t)] for t in (
|
|
"Prices for today are in", "The 15-minute demand forecast")}
|
|
check("the wizard's price line, and the peak forecast unit note, are among "
|
|
"them", all(_l.values()))
|
|
check("note_text() picks: Dutch for nl, English for en, the old twin table "
|
|
"for a note with no `nl`",
|
|
note_text(_l["Prices for today are in"][0], "nl")
|
|
== "De prijzen van vandaag zijn binnen, voor 18 van 24 uur."
|
|
and note_text(_l["Prices for today are in"][0], "en")
|
|
== "Prices for today are in, for 18 of 24 hours."
|
|
and note_text(Note("info", (), messages.FIXED_TARIFF_NOTE), "nl")
|
|
== messages.FIXED_TARIFF_NOTE_NL
|
|
and note_text(Note("info", (), "Other"), "nl") == "Other")
|
|
|
|
# --- over HTTP: the settings page and the wizard, in both languages --------- #
|
|
SCHEMA = D.schema_ui(D.CONFIG)
|
|
TRANSLATIONS = {l: TL.parse_translation_file(HERE / "translations" / f"{l}.yaml")
|
|
for l in ("en", "nl")}
|
|
STATES = [{"entity_id": e, "state": v["state"],
|
|
"attributes": ({"unit_of_measurement": v["unit"]} if v["unit"]
|
|
else {})} for e, v in GOOD.items()]
|
|
|
|
|
|
class Hass:
|
|
session = None
|
|
token = ""
|
|
|
|
async def limits(self, _e):
|
|
return (-5000.0, 5000.0, 1.0)
|
|
|
|
async def states(self):
|
|
return copy.deepcopy(STATES)
|
|
|
|
async def state(self, entity):
|
|
return next((copy.deepcopy(s) for s in STATES
|
|
if s["entity_id"] == entity), None)
|
|
|
|
|
|
class Supervisor:
|
|
def __init__(self, options):
|
|
self.options = copy.deepcopy(options)
|
|
|
|
async def info(self):
|
|
return {"options": copy.deepcopy(self.options), "schema": SCHEMA,
|
|
"translations": TRANSLATIONS}
|
|
|
|
async def write_options(self, options):
|
|
pass
|
|
|
|
async def restart(self, confirm):
|
|
pass
|
|
|
|
|
|
class Ctl:
|
|
def __init__(self, opts):
|
|
self.o, self.hass, self.inverters = dict(opts), Hass(), []
|
|
self.store, self.auto, self.grid = None, True, 402.0
|
|
|
|
def log_event(self, text, hint=None):
|
|
pass
|
|
|
|
def status_payload(self, lang="en"):
|
|
return {"banner": "", "level": "ok", "rows": [], "checks": [],
|
|
"plan": {"prices": [{"label": f"{h:02d}:00", "eur_kwh": 0.2}
|
|
for h in range(20)]
|
|
+ [{"label": f"{h:02d}:00", "eur_kwh": None}
|
|
for h in range(20, 24)]}}
|
|
|
|
|
|
async def serve(opts, steps):
|
|
runner = aioweb.AppRunner(web.build_app(Ctl(opts), Supervisor(opts)))
|
|
await runner.setup()
|
|
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
|
await site.start()
|
|
root = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/"
|
|
try:
|
|
async with ClientSession() as s:
|
|
return await steps(s, root)
|
|
finally:
|
|
await runner.cleanup()
|
|
|
|
|
|
async def post(s, url, body):
|
|
async with s.post(url, json=body) as r:
|
|
return await r.json()
|
|
|
|
|
|
RUN = dict(OPTS, meter_source="homewizard_local", p1_host="192.0.2.50",
|
|
price_provider="generic_dayahead")
|
|
|
|
|
|
async def pages(s, root):
|
|
out = {}
|
|
# The settings notes read the entity list the picker has cached, so the
|
|
# first call only warms it.
|
|
await post(s, root + "config/validate",
|
|
{"model": U.options_to_ui(RUN), "setup": True})
|
|
for lang in ("en", "nl"):
|
|
async with s.get(root + f"config/model?lang={lang}") as r:
|
|
m = await r.json()
|
|
model = U.options_to_ui(RUN)
|
|
out[lang] = await post(s, root + "config/validate",
|
|
{"model": model, "setup": True, "lang": lang})
|
|
out["none"] = await post(s, root + "config/validate", {
|
|
"model": U.options_to_ui(RUN), "setup": True})
|
|
return out
|
|
|
|
|
|
R = asyncio.run(serve(RUN, pages))
|
|
_texts = {l: [m["text"] for m in R[l].get("messages", [])] for l in ("en", "nl")}
|
|
_setup = {l: [m["text"] for m in R[l].get("setup", [])] for l in ("en", "nl")}
|
|
FC_EN = messages.peak_forecast_unit("kWh") + " Pick a sensor in W or kW."
|
|
FC_NL = messages.peak_forecast_unit("kWh", "nl") + " Kies een sensor in W of kW."
|
|
print(f" settings notes en: {_texts['en']}")
|
|
print(f" settings notes nl: {_texts['nl']}")
|
|
print(f" wizard lines nl: {_setup['nl']}")
|
|
check("settings page, English: the peak forecast unit note is English",
|
|
FC_EN in _texts["en"])
|
|
check("settings page, Dutch: it is the Dutch sentence, and no English "
|
|
"sentence of it is left", FC_NL in _texts["nl"]
|
|
and FC_EN not in _texts["nl"])
|
|
check("wizard, English: the price line and the checks are English",
|
|
"Prices for today are in, for 20 of 24 hours." in _setup["en"])
|
|
check("wizard, Dutch: the price line is Dutch",
|
|
"De prijzen van vandaag zijn binnen, voor 20 van 24 uur."
|
|
in _setup["nl"] and "Prices for today are in, for 20 of 24 hours."
|
|
not in _setup["nl"])
|
|
_left = [t for t in _setup["nl"] + _texts["nl"]
|
|
if ENGLISH.search(QUOTED.sub("X", t))
|
|
and not t.startswith("Home Assistant has no entity")]
|
|
check(f"no wizard line or settings note on the Dutch page has English words "
|
|
f"left ({_left})", not _left)
|
|
check("a request with no language gets English (an old page keeps working)",
|
|
[m["text"] for m in R["none"].get("setup", [])]
|
|
== [m["text"] for m in R["en"].get("setup", [])])
|
|
check("the raw `notes` list keeps the English `text` and adds `nl`",
|
|
R["nl"]["notes"] and all(n["text"] != n["nl"] for n in R["nl"]["notes"]))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n3. labels")
|
|
# --------------------------------------------------------------------------- #
|
|
for lang in ("en", "nl"):
|
|
name = TRANSLATIONS[lang]["configuration"]["peak_forecast_entity"]["name"]
|
|
check(f"[{lang}] the peak forecast label has no unit: {name!r}",
|
|
"(W)" not in name and "(kW)" not in name
|
|
and name.split("·")[-1].strip() == (
|
|
"15-minute demand forecast entity" if lang == "en"
|
|
else "Entiteit kwartierprognose"))
|
|
check("DOCS.md shows the same label, so the table cannot drift",
|
|
"| Limits | 15-minute demand forecast entity | `peak_forecast_entity` |"
|
|
in (HERE / "DOCS.md").read_text(encoding="utf-8"))
|
|
|
|
|
|
def stems(key):
|
|
tr = TRANSLATIONS["nl"]["configuration"]
|
|
out = []
|
|
for c in (tr, tr["inverters"]["fields"]):
|
|
if isinstance(c.get(key), dict):
|
|
n = c[key]["name"].split("·")[-1].strip()
|
|
while re.search(r"\s*\([^()]*\)$", n):
|
|
n = re.sub(r"\s*\([^()]*\)$", "", n)
|
|
out.append(n)
|
|
return out
|
|
|
|
|
|
_drift = sorted(k for k, v in messages.LABELS_NL.items() if v not in stems(k))
|
|
check(f"every Dutch label a note quotes is the setting's name in nl.yaml "
|
|
f"(drifted: {_drift})", not _drift
|
|
and set(messages.LABELS_NL) == set(messages.LABELS))
|
|
_unit = sorted(k for k, v in messages.UNIT_LABELS_NL.items() if v not in stems(k))
|
|
check(f"...and so are the two the inverter list names differently ({_unit})",
|
|
not _unit)
|
|
check("setting() in Dutch keeps the key, so the config panel can find it",
|
|
messages.setting("max_w", "", "nl") == "“Vermogen van de omvormer” (max_w)"
|
|
and messages.setting("max_w", "inverter-b", "nl")
|
|
== "“Vermogen van deze omvormer” voor inverter-b (inverters.max_w)"
|
|
and messages.setting("max_w") == "“Inverter rating” (max_w)")
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n4. the rules catch spoiled input")
|
|
# --------------------------------------------------------------------------- #
|
|
check("a Dutch row left in English is caught",
|
|
row_problems_nl(messages.AMBER, "Control: last ran 2 seconds ago"))
|
|
check("an over-long Dutch row is caught",
|
|
row_problems_nl(messages.AMBER, " ".join(["woord"] * 16) + "."))
|
|
check("Dutch jargon is caught", dutch_problems("Het commando is geweigerd"))
|
|
check("a row with no Dutch is caught",
|
|
not isinstance(messages.pick("x", "nl"), messages.Bi)
|
|
and not getattr(messages.Bi("x"), "nl"))
|
|
check("the untranslated-call scan sees a bare _advise",
|
|
any(isinstance(n, ast.Call) and not any(k.arg == "nl" for k in n.keywords)
|
|
for n in ast.walk(ast.parse('_advise(notes, "warn", (), "x")'))))
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED:")
|
|
for f in fails:
|
|
print(" -", f)
|
|
sys.exit(1)
|
|
print("all passed")
|