UX-13 P5: Dutch text, STYLE check, DOCS screenshots, period editor
checks / unit (pull_request) Successful in 10m45s
infra-pin / fresh (pull_request) Successful in 1s

- app/ui_text/nl.json and app/uitext.py: every page word in Dutch, English
  fallback per key; the page picks by browser language. Home's sentences
  move into ui_text `home_words`, so /status?lang=nl builds them in Dutch.
- test_ui_text.py: EN/NL key and placeholder parity, STYLE limits, jargon
  lists for both languages, no inline details, untranslated Dutch caught.
  Shared rules moved unchanged to tools/style_rules.py.
- tools/ux13_screenshots.sh/.py: DOCS screenshots from the demo fixtures in
  headless Chromium; docs/img/ux13/. Battery modes (P3): no screenshot.
- "Other time windows": a list editor (ui.periods) that builds
  static_profile and names bad times, empty prices, overlaps and gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude dev agent
2026-09-28 16:33:32 +02:00
parent 8c34647d05
commit 667b8e9145
38 changed files with 1633 additions and 300 deletions
+58
View File
@@ -0,0 +1,58 @@
"""STYLE.md sections 1 and 4 as code: the banned words and the counts.
Shared by test_messages.py (UX-08) and test_ui_text.py (UX-13 P5), so the two
checks cannot drift apart. Moved here unchanged from test_messages.py.
"""
import re
BANNED = re.compile(
r"\b(?:(?:SAFETY|TARIFF|TEL|CFG|FCST|OPT|UX|HW|FW|WITNESS|PKG|ARCH)-\d+|"
r"integrator|anti-?windup|windup|clamp(?:ed|s)?|i_clamped|saturat\w*|"
r"BUG SIGNAL|latch(?:ed)?|arbiter|allocator|registers?|0x[0-9A-Fa-f]+|"
r"layer [123]|heartbeat|watchdog|telegrams?|ACKs?|CRC|OTA|Modbus|planner|"
r"capaciteitstarief|journalctl|ssh|grep|"
r"\d+\.\d+\.\d+\.\d+|[\w-]+\.(?:py|md|yaml|json|db))\b|§\s?\d", re.I)
ENTITY = re.compile(r"\b(?:sensor|number|binary_sensor|input_\w+|switch|select)"
r"\.[a-z0-9_*]+")
SNAKE = re.compile(r"\b[a-z0-9]+(?:_[a-z0-9]+)+\b")
SHOUT = re.compile(r"\b[A-Z]{2,}[A-Z0-9]*(?:[\s-]+[A-Z]{2,}[A-Z0-9]*)+\b")
PLACEHOLDER = re.compile(r"\{[^{}]*\}|%[-+ #0-9.]*[sdrfgx%]")
def words(text):
return [w for w in re.findall(r"\S+", PLACEHOLDER.sub("X", text))
if re.search(r"[A-Za-z0-9]", w)]
def sentences(text):
"""Sentence count: a stop followed by a space and a capital, or the end."""
t = PLACEHOLDER.sub("X", text.strip())
t = re.sub(r"\b(e\.g|i\.e|etc)\.", "eg", t)
return len([s for s in re.split(r"[.!?](?:\s+|$)", t) if s.strip()])
def title_problems(title):
out = []
if len(words(title)) > 5:
out.append(f"title over 5 words ({len(words(title))})")
if title.lower().startswith("goodwe"):
out.append("title carries a GoodWe prefix")
if title.rstrip().endswith("."):
out.append("title ends in a full stop")
return out + text_problems(title, keys=False, entities=False)
def text_problems(text, *, keys, entities):
t = PLACEHOLDER.sub("X", text)
out = []
hit = sorted({m.group(0) for m in BANNED.finditer(t)})
if hit:
out.append("banned: " + ", ".join(hit[:4]))
if not entities and ENTITY.search(t):
out.append("entity id: " + ENTITY.search(t).group(0))
if not keys and SNAKE.search(ENTITY.sub("", t)):
out.append("option key: " + SNAKE.search(ENTITY.sub("", t)).group(0))
if SHOUT.search(t):
out.append("capitals: " + SHOUT.search(t).group(0))
return out
+19 -3
View File
@@ -1,7 +1,8 @@
"""UX-13: serve the real page and routes over a fixture controller, for a
headless browser check. `python3 tools/ux13_demo.py STATE PORT [HOST]`
STATE is running, shadow or alarm. The controller is the shipped `Controller`
STATE is running, shadow, alarm, new (P4) or setup (UX-15's setup-only
mode, P5). The controller is the shipped `Controller`
with its readings set by hand (two batteries, solar, today's prices in the
mockup's shape, trading on). Nothing here talks to Home Assistant or to an
inverter: `/action` changes only this fixture. Used for the P1 render check
@@ -35,6 +36,7 @@ from aiohttp import web as aioweb # noqa: E402
from app import homeview, web # noqa: E402
from app.main import Controller # noqa: E402
from app.setuponly import SetupOnly # noqa: E402
from app.peakguard import PeakDecision # noqa: E402
from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402
from app.static_tariff import TariffPlan # noqa: E402
@@ -203,8 +205,9 @@ class Supervisor:
async def info(self):
return {"options": json.loads(json.dumps(self.options)),
"schema": schema_ui(CONFIG),
"translations": {"en": TL.parse_translation_file(
os.path.join(HERE, "translations", "en.yaml"))}}
"translations": {lang: TL.parse_translation_file(
os.path.join(HERE, "translations", f"{lang}.yaml"))
for lang in ("en", "nl")}}
async def write_options(self, options):
self.options = json.loads(json.dumps(options))
@@ -255,7 +258,20 @@ def controller(state):
return c
class ReadOnlyHass(Hass):
read_only = True # app/setuponly.py serves only on a read-only HA
async def main(state, port, host="127.0.0.1"):
if state == "setup":
# UX-15's setup-only mode: shipped defaults, nothing controlled.
stored = defaults(CONFIG)
c = SetupOnly(dict(stored), ReadOnlyHass(), Store())
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
await runner.setup()
await aioweb.TCPSite(runner, host, port).start()
print(f"serving setup on http://127.0.0.1:{port}/", flush=True)
await asyncio.Event().wait()
if state == "new":
stored = dict(defaults(CONFIG), setpoint_entity="number.a")
store = Store()
@@ -0,0 +1,96 @@
"""UX-13 P5: the DOCS screenshots of the new screens, taken from the real page.
Run by tools/ux13_screenshots.sh, which serves tools/ux13_demo.py fixtures and
runs this inside the Playwright image. Each shot fails the run on a page
error, so a broken screen is never pictured.
python3 ux13_screenshots.py OUT_DIR # DEMO_<STATE> = the fixture's URL
The battery-mode screens (P3) are not built, so there is no shot of them:
DOCS.md says so instead of showing a mock-up.
"""
import os
import sys
from playwright.sync_api import sync_playwright
OUT = sys.argv[1]
DESKTOP = {"width": 1000, "height": 900}
PHONE = {"width": 390, "height": 844}
def url(state):
return os.environ[f"DEMO_{state.upper()}"]
def shot(browser, name, state, route, *, lang="en-GB", size=DESKTOP,
full=True, act=None, only=None):
ctx = browser.new_context(locale=lang, viewport=size,
color_scheme="light", reduced_motion="reduce")
page = ctx.new_page()
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.goto(url(state) + route)
page.wait_for_load_state("networkidle")
page.wait_for_timeout(1500) # the first /status and checks
if act:
act(page)
page.wait_for_timeout(1500) # the debounced start checks
path = os.path.join(OUT, name + ".png")
if only:
page.locator(only).screenshot(path=path)
else:
page.screenshot(path=path, full_page=full)
ctx.close()
if errors:
raise SystemExit(f"{name}: page errors {errors}")
print(f" {name}.png")
def open_tariff(page):
page.locator("details.grp[data-g=tariff] > summary").click()
page.locator("details.grp[data-g=meter] > summary").click() # close it
# The fixture site is on a dynamic contract: its fallback gets windows.
page.get_by_role("radiogroup", name="Price when the feed is down") \
.get_by_role("radio", name="Other time windows").click()
def windows(page, overlap=False):
open_tariff(page)
add = page.get_by_role("button", name="+ Add a time window")
rows = (("07:00", "23:00" if overlap else "22:00", "mon-fri", "0.34"),
("22:00", "07:00", "mon-fri", "0.26"),
("00:00", "24:00", "sat,sun", "0.24"))
for i, (a, b, days, price) in enumerate(rows, start=1):
add.click()
page.get_by_label(f"Window {i}, From").fill(a)
page.get_by_label(f"Window {i}, To").fill(b)
page.get_by_label(f"Window {i}, Days").select_option(days)
page.get_by_label(f"Window {i}, Price").fill(price)
page.locator("[data-key='ui.periods']").scroll_into_view_if_needed()
def main():
os.makedirs(OUT, exist_ok=True)
with sync_playwright() as p:
b = p.chromium.launch()
shot(b, "home-running", "running", "#home")
shot(b, "home-running-nl", "running", "#home", lang="nl-BE")
shot(b, "home-shadow", "shadow", "#home")
shot(b, "home-phone", "running", "#home", size=PHONE)
shot(b, "home-setup-only", "setup", "#home", full=False)
shot(b, "settings-installer", "running", "#settings")
shot(b, "settings-installer-nl", "running", "#settings", lang="nl-BE")
tariff = "details.grp[data-g=tariff]"
shot(b, "settings-time-windows", "running", "#settings",
act=windows, only=tariff)
shot(b, "settings-time-windows-overlap", "running", "#settings",
act=lambda page: windows(page, overlap=True), only=tariff)
shot(b, "setup-step-1", "new", "#setup")
shot(b, "setup-step-1-nl", "new", "#setup", lang="nl-BE")
b.close()
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
# UX-13 P5: regenerate the DOCS screenshots in docs/img/ux13/ from the real page.
#
# sh goodwe_controller/tools/ux13_screenshots.sh [OUT_DIR]
#
# Serves tools/ux13_demo.py's fixtures (running, shadow, new, setup) in the CI
# test image, then runs tools/ux13_screenshots.py in the Playwright image on a
# private Docker network (the image has the browsers; the matching Python
# package is installed into the container on each run). Nothing talks to Home Assistant or an inverter. The
# prices and the "now" line follow the clock, so the pictures change a little
# from run to run. Needs Docker and PyPI access; pulls the Playwright image once.
set -eu
HERE=$(cd "$(dirname "$0")/.." && pwd)
OUT=${1:-$HERE/../docs/img/ux13}
PW=mcr.microsoft.com/playwright/python:v1.55.0-noble
NET=ux13-shots-$$
STATES="running shadow new setup"
mkdir -p "$OUT"
OUT=$(cd "$OUT" && pwd)
cleanup() {
for s in $STATES; do docker rm -f "$NET-$s" >/dev/null 2>&1 || true; done
docker network rm "$NET" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
docker build -q -t goodwe-addon-checks:1 "$HERE/../.gitea/ci" >/dev/null
docker network create "$NET" >/dev/null
ENVS=""
for s in $STATES; do
docker run -d --name "$NET-$s" --network "$NET" -e PYTHONDONTWRITEBYTECODE=1 \
-v "$HERE:/w:ro" goodwe-addon-checks:1 \
python3 /w/tools/ux13_demo.py "$s" 8099 0.0.0.0 >/dev/null
ENVS="$ENVS -e DEMO_$(echo "$s" | tr a-z A-Z)=http://$NET-$s:8099/"
done
sleep 3
# shellcheck disable=SC2086
docker run --rm --network "$NET" --user "$(id -u):$(id -g)" -e HOME=/tmp \
$ENVS -v "$HERE/tools:/t:ro" -v "$OUT:/out" "$PW" \
sh -euc 'pip install -q --disable-pip-version-check --target /tmp/pw \
playwright==1.55.0
PYTHONPATH=/tmp/pw python3 /t/ux13_screenshots.py /out'
echo "screenshots in $OUT"
+28 -1
View File
@@ -10,7 +10,8 @@ own validator run over them.
The wizard edits the page model one step at a time and saves it through
uipage.to_options(), like the Installer view. `blobs()` walks each step's
choices (meter source and sign, one to three inverters, solar rows, every
contract and price source, region, peak limit, shadow mode) from a new
contract and price source, time windows (P5), region, peak limit, shadow
mode) from a new
install as shipped, one with a power request entity, and the TEST-23 sites, and keeps what the shipped validator
accepts, because the wizard saves nothing it refuses. test_setup.py checks
the set; `--supervisor` runs it through Supervisor 2026.08.0's `AppOptions`
@@ -103,6 +104,29 @@ PRICES = {
"csv": {"ui.contract": "dynamic", "price_provider": "manual_csv",
"price_csv_path": "/share/prices.csv", "ui.fallback": "fixed",
"ui.fixed_price": 0.3},
# P5: the time-window editor, odd windows the day/night preset cannot say.
"periods": {"ui.contract": "custom", "ui.fallback": "custom",
"ui.periods": [
{"from": "06:30", "to": "21:15", "days": "mon-fri",
"price": 0.31},
{"from": "21:15", "to": "06:30", "days": "mon-fri",
"price": 0.19},
{"from": "00:00", "to": "12:00", "days": "sat",
"price": 0.2},
{"from": "12:00", "to": "24:00", "days": "sat",
"price": 0.25},
{"from": "00:00", "to": "24:00", "days": "sun",
"price": 0.18}]},
"periods_one": {"ui.contract": "custom", "ui.fallback": "custom",
"ui.periods": [{"from": "00:00", "to": "00:00",
"days": "mon-sun", "price": 0.29}]},
"dynamic_periods": {"ui.contract": "dynamic",
"price_provider": "generic_dayahead",
"ui.fallback": "custom", "ui.periods": [
{"from": "07:00", "to": "23:00",
"days": "mon-sun", "price": 0.32},
{"from": "23:00", "to": "07:00",
"days": "mon-sun", "price": 0.21}]},
}
REGIONS = {"flanders": {"ui.region": "flanders"},
"own": {"ui.region": "own", "price_taxes_eur_kwh": 0.15}}
@@ -139,6 +163,9 @@ def plans():
"shadow": "on"},
{"meter": "mqtt", "roster": "two", "solar": "none",
"prices": "formula", "region": "flanders", "peak": "low",
"shadow": "on"},
{"meter": "dsmr", "roster": "two", "solar": "one",
"prices": "periods", "region": "flanders", "peak": "forecast",
"shadow": "on"}):
seen.append(plan)
return seen