UX-13 P5: Dutch text, STYLE check, DOCS screenshots, period editor
- 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:
@@ -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
|
||||
@@ -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()
|
||||
Executable
+43
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user