checks / unit (pull_request) Successful in 8m39s
Some PV entities (kWh meters, ESPHome clamps in the PV circuit) read positive for consumption and negative for production. `pv_invert_entities` lists the ones to flip; the flip is applied per member inside pv.total(), before the sum, so the MQTT `pv` row and the status page carry the corrected figure. Default [] is byte-identical to 0.12.0. Shape measured in supervisor_shape_probe.py section H (Supervisor 2026.08.0): a parallel list validates the 0.12.0 blob unchanged and renders as its own translated repeatable field; dicts on pv_entities refuse 0.12.0's stored strings; a prefix is invisible syntax; a global bool cannot describe a mixed roster. Status page marks flipped entities and cautions per entity below -50 W, saying ADD or REMOVE. Telemetry only: control documents unchanged. Record: DECISIONS/TEL-14-pv-sign.md (reverses TEL-11 section 2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
762 lines
42 KiB
Python
762 lines
42 KiB
Python
"""CFG-04: does Supervisor accept a list-of-objects option? MEASURED, not assumed.
|
|
|
|
⚠️ WHY THIS SCRIPT EXISTS AND WHY IT IS COMMITTED. config.yaml had no
|
|
list-of-objects option anywhere before CFG-04, and `static_profile` carries a
|
|
structured value as a JSON STRING in a `str?` precisely because nobody knew
|
|
whether the Supervisor's options validator supported a nested one. That is a
|
|
question about a specific version of a specific piece of somebody else's
|
|
software, and the honest way to answer it is to run that software.
|
|
|
|
WHAT IT RUNS. Supervisor's OWN `AppOptions` validator and its OWN
|
|
`SCHEMA_APP_CONFIG`, out of the release image, against THIS repository's
|
|
config.yaml. Nothing here is a re-implementation of the validation rules; if
|
|
Supervisor changes them, this script changes its answer.
|
|
|
|
HOW TO RUN IT (the version is the one `ha info` reports on the target install -
|
|
2026.08.0 is what 192.168.2.158 ran when CFG-04 landed):
|
|
|
|
docker run --rm \
|
|
-v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" -v /tmp/out:/out \
|
|
--entrypoint python3 \
|
|
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
|
/probe/supervisor_shape_probe.py
|
|
|
|
⚠️ IT TOUCHES NOTHING. It reads config.yaml and runs a validator in a throwaway
|
|
container. It does not talk to the live Supervisor, does not POST options and
|
|
does not install anything - the schema conflict in docs/deploy-rollback.md §4 is
|
|
exactly what you do NOT want to discover on a running site.
|
|
|
|
WHAT IT ANSWERED, 2026-09-04, on 2026.08.0 - the full output is in
|
|
DECISIONS/CFG-04-inverter-roster.md:
|
|
|
|
A. The manifest, list-of-objects schema and all, is ACCEPTED.
|
|
B. The shipped `options:` block validates as it stands, and the SAME block
|
|
with ONLY `inverters` removed is refused BY NAME - `Missing option
|
|
'inverters' in root`. That is the one measurement the `inverters: []`
|
|
default rests on, and it is an ISOLATED counterfactual: exactly one key
|
|
varies between the two runs, so the refusal cannot be about anything else.
|
|
B2. A reconstructed pre-0.6.0 options blob, merged with those defaults,
|
|
validates. Its RAW form is refused - but for whichever required key the
|
|
reconstruction happens to lack FIRST, which is an artefact of the
|
|
reconstruction and NOT evidence about `inverters`. B is that evidence.
|
|
C. A two-inverter blob validates, per-entry types enforced.
|
|
D. An entry missing `name`, `setpoint_entity`, `max_w` or `bms_soc_floor` is
|
|
refused BY NAME - one key varied from an ACCEPTED baseline, and the key's
|
|
own name asserted in the refusal - and a JSON string where the list belongs
|
|
is refused outright.
|
|
|
|
⚠️ RE-RUN 2026-09-04 FOR SAFETY-32, which made `bms_soc_floor` a REQUIRED
|
|
per-entry key. That change broke §C and, worse, left §D printing PASS on a
|
|
refusal about the WRONG KEY - the exact decay MUTATION.md habit 8 describes. §C
|
|
now carries the key and §D is an isolated counterfactual asserting the key name.
|
|
|
|
⚠️ RE-RUN AGAIN 2026-09-05, on the same 2026.08.0 image, because SAFETY-32's
|
|
rebase onto SPIKE-04a merged two independent edits into §C and §D - the required
|
|
`bms_soc_floor` and inverter B's datasheet `max_w: 4600`. A transcript stitched
|
|
together by hand from two runs is not a measurement, so this one was measured:
|
|
`all cases passed`, exit 0. B's 4600 W is STILL its DATASHEET figure and not a
|
|
reading off any unit, and B's `bms_soc_floor: 15` in §C is illustrative - it
|
|
differs from A's 10 so that the per-entry number is exercised at all, while
|
|
production is expected to have BOTH packs at 10 %.
|
|
"""
|
|
import sys, json, copy
|
|
sys.path.insert(0, "/usr/src/supervisor")
|
|
import voluptuous as vol
|
|
from voluptuous.humanize import humanize_error
|
|
from deepmerge import Merger
|
|
from supervisor.apps.validate import SCHEMA_APP_CONFIG
|
|
from supervisor.apps.options import AppOptions, UiOptions
|
|
from pathlib import Path
|
|
from supervisor.utils.yaml import read_yaml_file
|
|
|
|
MERGER = Merger(type_strategies=[(dict, ["merge"])],
|
|
fallback_strategies=["override"], type_conflict_strategies=["override"])
|
|
|
|
raw = read_yaml_file(Path("/w/config.yaml"))
|
|
print("== A. the whole config.yaml through SCHEMA_APP_CONFIG ==")
|
|
try:
|
|
cfg = SCHEMA_APP_CONFIG(raw)
|
|
print(" PASS Supervisor accepts the add-on manifest, version", cfg["version"])
|
|
except vol.Invalid as e:
|
|
print(" FAIL ", humanize_error(raw, e)); sys.exit(1)
|
|
|
|
schema, defaults = cfg["schema"], cfg["options"]
|
|
print(" schema declares", len(schema), "keys; inverters ->", json.dumps(schema["inverters"]))
|
|
# .get, not [], deliberately: if someone deletes the `inverters: []` default
|
|
# this line must not crash the probe before section B gets to REFUSE it.
|
|
print(" options ship inverters ->", json.dumps(defaults.get("inverters")))
|
|
|
|
def validate_with(sch, options):
|
|
"""`(ok, result-or-refusal)` for ONE schema and ONE options blob.
|
|
|
|
⚠️ TEL-12 §G VARIES THE SCHEMA rather than only the options, which is what a
|
|
question about a KEY'S SHAPE requires. `validate()` below is this with the
|
|
shipped schema and is what every earlier section calls, unchanged.
|
|
"""
|
|
v = vol.Schema(vol.All(dict, AppOptions(None, sch, cfg["name"],
|
|
"local_goodwe_controller")))
|
|
try:
|
|
return True, v(copy.deepcopy(options))
|
|
except vol.Invalid as e:
|
|
return False, str(e)
|
|
|
|
|
|
def validate(options):
|
|
return validate_with(schema, options)
|
|
|
|
FAILED = []
|
|
|
|
def show(t, ok, d=""):
|
|
print(f" {'PASS' if ok else 'FAIL'} {t}")
|
|
if d: print(" " + str(d)[:300])
|
|
if not ok: FAILED.append(t)
|
|
|
|
# The live install's persisted options, reconstructed from HANDOVER.md §1 and
|
|
# docs/deploy-rollback.md §0/§B.5. No `inverters` key: it did not exist.
|
|
LIVE = {
|
|
"meter_entity": "sensor.p1_meter_active_power", "meter_invert": False,
|
|
"soc_entity": "sensor.goodwe_battery_soc", "batt_entity": "sensor.goodwe_battery_power",
|
|
"batt_invert": False,
|
|
"setpoint_entity": "number.kelder_goodwe_master_stage1_goodwe_setpoint_w",
|
|
"meter_source": "homewizard_local", "meter_phases": 1, "meter_max_age_s": 30,
|
|
"meter_poll_s": 5, "p1_host": "192.168.2.250",
|
|
"max_w": 5000, "gain": 0.6, "slew_w": 1000, "deadband_w": 15, "target_grid_w": -10,
|
|
"step_w": 10, "saturation_w": 500, "saturation_cycles": 3, "integrator_max_w": 0,
|
|
"heartbeat_s": 10, "stale_input_s": 15, "cycle_floor_s": 15, "auto_start": False,
|
|
"maintenance_enabled": False, "estop_fitted": True, "log_level": "info",
|
|
}
|
|
|
|
print("\n== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==")
|
|
# ⚠️ THIS IS AN ISOLATED COUNTERFACTUAL AND IT HAS TO STAY ISOLATED. The claim
|
|
# the `inverters: []` default rests on is about ONE key, so exactly ONE key
|
|
# varies between these two runs: the full shipped `options:` block, and the same
|
|
# block with `inverters` removed and nothing else touched. Feeding the validator
|
|
# some other, INCOMPLETE blob would also produce a refusal - but about whichever
|
|
# required key that blob lacks first, which is a different measurement wearing
|
|
# this one's label. §B2 below is that other blob, and it is labelled as such.
|
|
ok, out = validate(dict(defaults))
|
|
show("the shipped `options:` block validates AS IT STANDS - so anything refused "
|
|
"on the next line is refused for the one key that was removed", ok,
|
|
"" if ok else out)
|
|
_no_inv = {k: v for k, v in defaults.items() if k != "inverters"}
|
|
ok_ni, out_ni = validate(_no_inv)
|
|
# ⚠️ THE ASSERTION IS ON THE KEY NAME, NOT ON `not ok`, AND THAT IS THE POINT.
|
|
# A refusal is only evidence for THIS key if it NAMES this key. Asserted as a
|
|
# bare `not ok`, this line would go on printing PASS after someone deleted
|
|
# `inverters: []` from config.yaml - blind to the exact regression it is the
|
|
# committed evidence for. Asserted on the string, it goes red: with the default
|
|
# gone the line above fails too, because then nothing distinguishes the two runs.
|
|
_names_it = (not ok_ni) and "Missing option 'inverters' in root" in str(out_ni)
|
|
show("...and the SAME block with ONLY `inverters` removed is REFUSED, BY NAME - "
|
|
"which is every options blob written before 0.6.0, since the key did not "
|
|
"exist when they were written", _names_it, out_ni)
|
|
|
|
print("\n== B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version ==")
|
|
# ⚠️ WHAT THIS SECTION IS AND IS NOT. LIVE is an INCOMPLETE reconstruction - it
|
|
# carries the keys HANDOVER.md and deploy-rollback.md record and no others, so
|
|
# it is missing many required keys that have nothing to do with this card. Its
|
|
# raw refusal is therefore about whichever of THOSE the validator reaches first
|
|
# (it has printed `meter_freeze_s` and `p1_phase_export_entities` on different
|
|
# runs), NOT about `inverters`. It is kept because the MERGED half is a real
|
|
# demonstration that `deepmerge(options:, stored)` is what App.options is; the
|
|
# raw half is printed only so the merge has something to be contrasted with.
|
|
# The `inverters` measurement is §B, where one key and only one key varies.
|
|
ok, out = validate(LIVE)
|
|
show("the reconstructed raw blob is REFUSED - for an unrelated missing key, "
|
|
"because the reconstruction is incomplete; see the refusal printed below "
|
|
"and §B for the `inverters` measurement", not ok, out)
|
|
merged = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(LIVE))
|
|
ok, out = validate(merged)
|
|
show("the same blob MERGED with these defaults - what App.options actually is", ok,
|
|
f"inverters={out.get('inverters')!r} setpoint_entity={out.get('setpoint_entity')!r} "
|
|
f"max_w={out.get('max_w')!r}" if ok else out)
|
|
|
|
print("\n== C. AC2: a two-inverter blob an operator would POST ==")
|
|
# ⚠️ B's 4600 W IS FROM ITS DATASHEET, NOT A PLACEHOLDER AND NOT A MEASUREMENT.
|
|
# SPIKE-04a (2026-09-04) identified inverter B from the owner's model number and
|
|
# serial - a GoodWe GW5048D-ES, serial `55048ESU22AW0992` - and the datasheet's
|
|
# Max. Charging / Discharging Power is 4600 W. The 3000 W that stood here was a
|
|
# hypothesis from before the unit was known and was simply wrong; the 5000 VA AC
|
|
# nameplate is wrong for this purpose too, because nothing will be connected to
|
|
# B's MPPTs so the battery path is the ceiling. See goodwe/NOTES.md §1.1b.
|
|
#
|
|
# ⚠️ AND IT IS STILL NOT MEASURED. The datasheet prints 4600 W under BATTERY
|
|
# INPUT DATA, so it is probably DC, while `max_w` is graded against 0x0518 - the
|
|
# inverter's own AC power. If it is DC the AC ceiling is nearer 4600 x 0.94 =
|
|
# 4324 W. NOTES.md §1.1b records both readings without picking one, and
|
|
# SAFETY-20's `min(max_w, dev_max)` is what bounds it correctly once dev_max is
|
|
# read off the unit. So this blob is what an operator would POST TODAY, not a
|
|
# claim about what inverter B will turn out to do.
|
|
#
|
|
# ⚠️ THE TWO RATINGS ARE STILL DIFFERENT (5000 / 4600) AND MUST STAY DIFFERENT.
|
|
# This file's own §B is the record of what an instrument that cannot distinguish
|
|
# its two cases is worth. Note that 5000/4600 is a much NARROWER separation than
|
|
# the 5000/3000 it replaces - fine here, where nothing computes on the numbers,
|
|
# and NOT fine in the test fixtures, which is why those keep a wide illustrative
|
|
# pair instead of the real one.
|
|
#
|
|
# ⚠️ AND `bms_soc_floor` IS A REQUIRED PER-ENTRY KEY SINCE SAFETY-32, so this
|
|
# blob is also what proves an operator can still POST a two-inverter config
|
|
# after that. THE TWO FLOORS DIFFER ON PURPOSE (10 and 15): equal values would
|
|
# not exercise a per-entry number at all, for the same reason the two ratings
|
|
# must stay different.
|
|
# ⚠️ NEITHER OF B'S NUMBERS IS A MEASUREMENT OF A PACK. Only inverter A's 10 %
|
|
# is read off hardware (register 0x0560 on the reference GW5000S-BP). B's 4600 W
|
|
# is its DATASHEET figure and B's 15 % is an illustrative value chosen to differ
|
|
# - there is no second pack on this site, and in production BOTH packs are
|
|
# expected to floor at 10 %, which is why the differing pair lives here and in
|
|
# the fixtures rather than in config.yaml's example.
|
|
TWO = dict(LIVE, setpoint_entity="", inverters=[
|
|
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
|
|
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
|
|
"max_w": 5000, "bms_soc_floor": 10},
|
|
{"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w",
|
|
"soc_entity": "sensor.goodwe_b_soc", "batt_entity": "sensor.goodwe_b_battery_power",
|
|
"max_w": 4600, "bms_soc_floor": 15}])
|
|
ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
|
|
show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out)
|
|
# The validated blob, for anyone who wants to POST it. Optional: the probe's
|
|
# whole job is the verdicts above, and a missing /out must not fail it.
|
|
try:
|
|
json.dump(out if ok else {}, open("/out/two_inverter_options.json", "w"), indent=1)
|
|
except OSError:
|
|
pass
|
|
|
|
print("\n== D. what the schema itself refuses, per entry ==")
|
|
# ⚠️ ONE KEY VARIED FROM AN ACCEPTED BASELINE, AND THE REFUSAL IS ASSERTED BY
|
|
# NAME - §B's two lessons, applied here because SAFETY-32 is what broke this
|
|
# section. These entries used to omit several keys at once and assert a bare
|
|
# `not ok`; the moment `bms_soc_floor` became required, the "no max_w" case went
|
|
# on printing PASS while the refusal it printed said `Missing option
|
|
# 'bms_soc_floor'`. A refusal of unknown provenance is not a measurement of
|
|
# anything, so the baseline below is COMPLETE and each case deletes exactly one
|
|
# key - or, for the range case, moves exactly one value out of range.
|
|
D_FULL = {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600,
|
|
"bms_soc_floor": 10}
|
|
# MERGED with the shipped defaults, exactly as §C is and as App.options is: the
|
|
# raw LIVE blob is missing root keys unrelated to this section (`maintenance_
|
|
# start_hour` on the run that caught it), and a baseline refused for one of THOSE
|
|
# is the provenance problem §B2 is kept as a warning about.
|
|
D_BASE = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO))
|
|
ok, out = validate(dict(D_BASE, inverters=[D_FULL]))
|
|
show("the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere "
|
|
"else to come from", ok, json.dumps(out.get("inverters")) if ok else out)
|
|
for title, entry, expect in (
|
|
("no name", {k: v for k, v in D_FULL.items() if k != "name"}, "name"),
|
|
("no setpoint_entity",
|
|
{k: v for k, v in D_FULL.items() if k != "setpoint_entity"}, "setpoint_entity"),
|
|
("no max_w", {k: v for k, v in D_FULL.items() if k != "max_w"}, "max_w"),
|
|
("no bms_soc_floor (SAFETY-32)",
|
|
{k: v for k, v in D_FULL.items() if k != "bms_soc_floor"}, "bms_soc_floor"),
|
|
("max_w over the range", dict(D_FULL, max_w=9999), "at most 5000"),
|
|
("bms_soc_floor over the range (SAFETY-32)",
|
|
dict(D_FULL, bms_soc_floor=99), "at most 50")):
|
|
ok, out = validate(dict(D_BASE, inverters=[entry]))
|
|
show(f"an entry with {title} is refused, and the refusal NAMES it",
|
|
(not ok) and expect in str(out), out)
|
|
ok, out = validate(dict(D_BASE, inverters="[]"))
|
|
show("a JSON STRING where the list belongs is refused", not ok, out)
|
|
|
|
|
|
print("\n== E. the ROLLBACK direction, and the operator's UI ==")
|
|
# The old (flat) schema, i.e. what an operator would be running after a
|
|
# downgrade, meeting options that carry an `inverters` list.
|
|
_flat_schema = {k: v for k, v in schema.items() if k != "inverters"}
|
|
_v = vol.Schema(vol.All(dict, AppOptions(None, _flat_schema, cfg["name"],
|
|
"local_goodwe_controller")))
|
|
try:
|
|
_rolled = _v(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
|
|
show("a schema WITHOUT `inverters` drops the key with a warning rather "
|
|
"than refusing - so a downgrade is not the §B trap in reverse",
|
|
"inverters" not in _rolled, sorted(_rolled)[:6])
|
|
except vol.Invalid as e:
|
|
show("a schema WITHOUT `inverters` drops the key", False, str(e))
|
|
|
|
from supervisor.apps.options import UiOptions # noqa: E402
|
|
try:
|
|
_ui = [e for e in UiOptions(None)(schema) if e.get("name") == "inverters"]
|
|
show("Supervisor's UI renderer turns it into a repeatable section rather "
|
|
"than choking - an operator can add the second inverter in the "
|
|
"Configuration tab, which the JSON-string alternative could not offer",
|
|
bool(_ui) and _ui[0].get("multiple") is True, json.dumps(_ui))
|
|
except Exception as e: # noqa: BLE001
|
|
show("Supervisor's UI renderer survives the shape", False, f"{type(e).__name__}: {e}")
|
|
|
|
|
|
print("\n== F. UX-01: the TRANSLATED Configuration tab, rendered ==")
|
|
# ⚠️ WHAT THIS SECTION MEASURES, AND WHY IT IS NOT AN ASSERTION ABOUT A FILE.
|
|
# UX-01 adds translations/en.yaml and translations/nl.yaml. Whether they reach
|
|
# an operator depends on two pieces of somebody else's software, not on us:
|
|
#
|
|
# 1. SUPERVISOR decides whether a file is loaded at all.
|
|
# `supervisor/store/data.py:_read_app_translations()` globs this directory,
|
|
# keys each file by its STEM, and runs it through `SCHEMA_APP_TRANSLATIONS`.
|
|
# ⚠️ A FILE THAT FAILS THAT SCHEMA IS DROPPED WITH A LOG WARNING AND
|
|
# NOTHING ELSE: `except (ConfigurationFileError, vol.Invalid): _LOGGER.
|
|
# warning(...); continue`. The add-on then renders raw keys and the only
|
|
# trace is one line in the Supervisor log. So the loader is RUN here, out
|
|
# of the release image, rather than the schema being re-implemented.
|
|
# 2. THE FRONTEND decides what it does with what Supervisor loaded. The
|
|
# resolution below is not invented: it is transcribed from the shipped
|
|
# bundle in THIS image, `api/panel/frontend_latest/5680.*.js`
|
|
# (frontend 20250925.1), which reads
|
|
#
|
|
# _getTranslationEntry(lang,s,o){let e=this.addon.translations[lang]?.
|
|
# configuration; if(e){if(o?.path)for(const t of o.path)
|
|
# if(e=e[t]?.fields,!e)return; return e[s.name]}}
|
|
# computeLabel =(s,_,o)=>this._getTranslationEntry(this.hass.language,s,o)?.name
|
|
# ||this._getTranslationEntry("en",s,o)?.name||s.name
|
|
# computeHelper=(s,o) =>this._getTranslationEntry(this.hass.language,s,o)?.description
|
|
# ||this._getTranslationEntry("en",s,o)?.description||""
|
|
# _filteredSchema=(opts,schema)=>schema.filter(e=>e.name in opts||e.required)
|
|
#
|
|
# i.e. the fallback is PER KEY, en is the second chance, and the raw key
|
|
# name is the third.
|
|
from supervisor.apps.validate import SCHEMA_APP_TRANSLATIONS # noqa: E402
|
|
from supervisor.store.data import _read_app_translations # noqa: E402
|
|
|
|
TRANSLATIONS = _read_app_translations(Path("/w"))
|
|
show("Supervisor's OWN translation loader accepts both files - a file that "
|
|
"fails SCHEMA_APP_TRANSLATIONS is DROPPED with a log warning and the tab "
|
|
"silently goes back to raw keys, so 'it is on disk' is not the question",
|
|
set(TRANSLATIONS) == {"en", "nl"}, sorted(TRANSLATIONS))
|
|
|
|
ui = UiOptions(None)(schema)
|
|
print(f" the Configuration tab renders {len(ui)} fields, in `schema:` order "
|
|
"(App.schema_ui is UiOptions(...)(data['schema']), so the ORDER IS THE "
|
|
"SCHEMA BLOCK'S and not the options block's)")
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# ⚠️ UX-04: THAT COUNT IS NOW GRADED, AND IT IS WHY UX-01's AC 3 IS WITHDRAWN.
|
|
#
|
|
# UX-01 AC 3 said the visible count would drop to ~15, with the rest behind
|
|
# HA's "show unused optional configuration options" toggle. The line above has
|
|
# printed the refutation since the day it was written - 71, not 15 - and PRINTED
|
|
# IS NOT GRADED: this probe exited 0 through the whole of UX-01's review while
|
|
# displaying the number that contradicted the criterion it was evidence for.
|
|
# That is the defect, not the count.
|
|
#
|
|
# THE FRONTEND RULE, transcribed from the bundle in THIS image at the top of
|
|
# this section and replayed rather than paraphrased:
|
|
#
|
|
# _filteredSchema = (opts, schema) => schema.filter(e => e.name in opts
|
|
# || e.required)
|
|
#
|
|
# `opts` is the add-on's OPTIONS AS SUPERVISOR SERVES THEM - the operator's
|
|
# stored blob after `options:` has been deep-merged into it - so a key is
|
|
# hideable only when it is BOTH absent from `options:` AND not required.
|
|
#
|
|
# WHY THE CRITERION IS WITHDRAWN RATHER THAN IMPLEMENTED, in two measurements:
|
|
#
|
|
# 1. IT IS ARITHMETICALLY UNREACHABLE. Supervisor's own UiOptions marks
|
|
# `required` on every non-`?` key, and the filter above shows a required
|
|
# key UNCONDITIONALLY. So the floor - what the tab renders with `options:`
|
|
# EMPTIED ALTOGETHER, which is the most hiding that is even expressible -
|
|
# is the required count below. It is nowhere near 15. No arrangement of
|
|
# `options:` can reach the target; the criterion was never satisfiable.
|
|
#
|
|
# 2. THE 12 KEYS THAT *COULD* BE HIDDEN COST THE DEEP-MERGE DEFAULT. A key
|
|
# absent from `options:` has nothing for the Supervisor to merge into an
|
|
# existing box's stored blob. That is exactly the `delivery_hold_s`
|
|
# mechanism the 0.9.0 upgrade rests on: a key that 0.7.0's stored blob
|
|
# cannot contain resolves to its `options:` default instead of failing
|
|
# validation, and moving it out of `options:` is precisely how you get the
|
|
# exit 78 that mechanism exists to prevent. Worse, it would promote the 27
|
|
# unpinned in-code literal fallbacks that `test_defaults_pin.py` part 4
|
|
# ledgers into the LOAD-BEARING source of those defaults on a live install
|
|
# - which is the CFG-03 defect the same card's AC 4 exists to police.
|
|
#
|
|
# So: four fewer clicks, bought with the upgrade path and with the defaults
|
|
# pin. The decision is written up in
|
|
# DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md
|
|
# and the three assertions below are what keeps it a decision rather than an
|
|
# omission - if anyone empties `options:` to shorten the form, this goes red.
|
|
_hideable = sorted(set(schema) - set(defaults))
|
|
_required = [n["name"] for n in ui if n.get("required")]
|
|
|
|
|
|
def _filtered(opts):
|
|
"""The frontend's own `_filteredSchema`, replayed on this manifest."""
|
|
return [n for n in ui if n["name"] in opts or n.get("required")]
|
|
|
|
|
|
show(f"every one of the {len(schema)} `schema:` keys is ALSO in `options:`, so "
|
|
f"schema-only - the only set HA's toggle can hide - is EMPTY. UX-01 AC 3 "
|
|
f"is not partially met, it is unstarted. Hideable: {_hideable}",
|
|
not _hideable and len(schema) == len(defaults))
|
|
show(f"...so replaying the frontend's own _filteredSchema on the served options "
|
|
f"hides NOTHING: {len(_filtered(defaults))} of {len(ui)} fields render",
|
|
len(_filtered(defaults)) == len(ui))
|
|
# ⚠️ DERIVED, NOT TYPED, AND NOT A FLOOR. The assertion above read
|
|
# `len(_required) > 15` while the prose said 59 - so a manifest change dropping
|
|
# the required count to 20 would have passed while the sentence beside it stayed
|
|
# wrong. That is the same shape as the count this repo already fixed in
|
|
# `test_translations.py`: a number displayed but not graded.
|
|
#
|
|
# So the expected value is computed from config.yaml by Supervisor's OWN rule -
|
|
# a key is optional iff its schema string ends in `?`, plus `inverters`, which
|
|
# `UiOptions` marks optional because it is a list - and the assertion is an
|
|
# EQUALITY against that. Typing 59 here would rot the first time a key is added.
|
|
_OPTIONAL = {k for k, v in schema.items()
|
|
if isinstance(v, str) and v.rstrip().endswith("?")} | {"inverters"}
|
|
_EXPECT_REQ = len(schema) - len(_OPTIONAL)
|
|
show(f"...and that floor is DERIVED: {len(schema)} schema keys minus "
|
|
f"{len(_OPTIONAL)} optional ones (the `?` suffix, plus `inverters`) is "
|
|
f"{_EXPECT_REQ}, and Supervisor's own required flag agrees",
|
|
_EXPECT_REQ == len(_required))
|
|
|
|
# ⚠️ AND THE WITHDRAWAL'S OWN PREMISE, GRADED SEPARATELY. The equality above is
|
|
# deliberately NOT enough on its own: both sides read the same `?` convention,
|
|
# so marking a key optional moves BOTH and it stays green. That is correct - it
|
|
# makes the printed number impossible to leave stale - but it grades Supervisor
|
|
# agreeing with us, not the thing AC 3 was withdrawn for.
|
|
#
|
|
# AC 3 was withdrawn because the floor is FAR above the ~15 it asked for. If
|
|
# enough keys ever became optional that the floor approached 15, the withdrawal
|
|
# stops being true and this file should say so rather than keep printing a
|
|
# sentence that has quietly become wrong.
|
|
_AC3_ASKED = 15
|
|
show(f"...and the floor of {_EXPECT_REQ} is still far above the ~{_AC3_ASKED} "
|
|
f"AC 3 asked for, which is the whole reason it was withdrawn. If this ever "
|
|
f"goes red the withdrawal needs re-arguing, not re-asserting",
|
|
_EXPECT_REQ > 2 * _AC3_ASKED)
|
|
|
|
|
|
# ⚠️ AN ISOLATED COUNTERFACTUAL, and the number is Supervisor's own `required`
|
|
# flag rather than a count typed here. `options:` is emptied and NOTHING else
|
|
# varies, so the answer cannot be about anything but the merge.
|
|
show(f"...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is "
|
|
f"expressible - the tab still renders {len(_filtered({}))} required "
|
|
f"fields. THAT is the floor, and AC 3 asked for ~15. The criterion is "
|
|
f"withdrawn as unsatisfiable, not deferred.",
|
|
len(_filtered({})) == len(_required) and len(_required) == _EXPECT_REQ)
|
|
|
|
|
|
|
|
|
|
|
|
def _entry(lang, name, path=()):
|
|
node = TRANSLATIONS.get(lang, {}).get("configuration")
|
|
if not node:
|
|
return None
|
|
for step in path:
|
|
node = (node.get(step) or {}).get("fields")
|
|
if not node:
|
|
return None
|
|
return node.get(name)
|
|
|
|
|
|
def _label(lang, name, path=()):
|
|
for lg in (lang, "en"):
|
|
e = _entry(lg, name, path)
|
|
if e and e.get("name"):
|
|
return e["name"], lg
|
|
return name, "RAW KEY"
|
|
|
|
|
|
def _helper(lang, name, path=()):
|
|
for lg in (lang, "en"):
|
|
e = _entry(lg, name, path)
|
|
if e and e.get("description"):
|
|
return e["description"], lg
|
|
return "", "NONE"
|
|
|
|
|
|
# ⚠️ EVERY FIELD, PRINTED. This is the transcript the card asks for: what the
|
|
# owner's Supervisor puts on the screen, not a claim that it would look nice.
|
|
for lang in ("en", "nl"):
|
|
print(f"\n --- as rendered at hass.language == {lang!r} "
|
|
f"(the owner's HA runs Europe/Brussels) ---")
|
|
for node in ui:
|
|
lbl, src = _label(lang, node["name"])
|
|
hlp, hsrc = _helper(lang, node["name"])
|
|
flag = "" if src == lang else f" [name from {src}]"
|
|
print(f" {node['name']:28s} -> {lbl}{flag}")
|
|
print(f" {'':28s} {hlp[:150]}{'...' if len(hlp) > 150 else ''}")
|
|
for sub in node.get("schema", ()):
|
|
slbl, ssrc = _label(lang, sub["name"], (node["name"],))
|
|
print(f" · {sub['name']:24s} -> {slbl}"
|
|
+ ("" if ssrc == lang else f" [{ssrc}]"))
|
|
|
|
_raw = [n["name"] for n in ui if _label("en", n["name"])[1] == "RAW KEY"]
|
|
show("NOT ONE top-level field falls through to its raw key name in `en` - "
|
|
f"which is what the tab rendered for all {len(ui)} of them before this "
|
|
f"card, because there was no translations/ directory at all. Raw: {_raw}",
|
|
not _raw)
|
|
_nohelp = [n["name"] for n in ui if not _helper("en", n["name"])[0]]
|
|
show("...and not one is left without a description, which is the half that "
|
|
f"says WHETHER TO TOUCH IT. Missing: {_nohelp}", not _nohelp)
|
|
_nl_missing = [n["name"] for n in ui if _label("nl", n["name"])[1] != "nl"]
|
|
show("...and every one of them is translated in `nl` too, so nothing on the "
|
|
f"owner's own screen falls back to English. Falling back: {_nl_missing}",
|
|
not _nl_missing)
|
|
|
|
# ⚠️ THE HONEST HALF, AND IT IS A NEGATIVE RESULT. `inverters` is a
|
|
# list-of-objects, so UiOptions gives it type "schema" WITH multiple: True, and
|
|
# `_convertSchemaElement` sends exactly that case to a SELECTOR rather than to a
|
|
# nested ha-form:
|
|
#
|
|
# if("schema"===t.type&&!t.multiple) return {...type:"expandable"...} <- not us
|
|
# if("schema"===t.type){const o={};for(const a of t.schema)
|
|
# o[a.name]={required:a.required,selector:...}; return {object:{multiple:t.multiple,fields:o}}}
|
|
#
|
|
# and ha-selector-object labels those fields with
|
|
#
|
|
# computeLabel=e=>{const t=this.selector.object?.translation_key;
|
|
# if(this.localizeValue&&t){...}
|
|
# return this.selector.object?.fields?.[e.name]?.label||e.name}
|
|
#
|
|
# `_convertSchemaElementToSelector` sets neither `label` nor `translation_key`,
|
|
# so ON THIS FRONTEND the per-entry keys inside the repeatable section render as
|
|
# `setpoint_entity`, `max_w`, `bms_soc_floor` - RAW - however well they are
|
|
# translated here. The `fields:` block is still shipped and still validated,
|
|
# because it is the shape Supervisor's own schema defines for exactly this and
|
|
# it is what a frontend that wires `label` through would consume; but this card
|
|
# must not claim it reaches the operator today, and the count below is asserted
|
|
# so that the day it changes, this transcript says so.
|
|
_multi = [n for n in ui if n.get("multiple") and n.get("type") == "schema"]
|
|
show("`inverters` is rendered as a REPEATABLE SECTION (type schema, multiple), "
|
|
"which is CFG-04's measurement re-confirmed",
|
|
[n["name"] for n in _multi] == ["inverters"], json.dumps(_multi)[:200])
|
|
show("⚠️ MEASURED AND NEGATIVE: this frontend gives that section's per-entry "
|
|
"keys to ha-selector-object, which labels them `fields[name].label || "
|
|
"name` - and the converter sets no `label` and no `translation_key`. So "
|
|
"the per-entry translations below are SHIPPED AND VALIDATED but do NOT "
|
|
"reach the screen on 20250925.1. The top-level `inverters` label and "
|
|
"description DO.",
|
|
bool(_multi) and all("label" not in s and "translation_key" not in s
|
|
for n in _multi for s in n.get("schema", ())),
|
|
"per-entry keys as rendered today: "
|
|
+ ", ".join(s["name"] for n in _multi for s in n.get("schema", ())))
|
|
|
|
# ⚠️ AND THE HAND PARSER CI USES IS GRADED AGAINST THIS LOADER, KEY BY KEY.
|
|
# test_translations.py runs in the add-on's CI image, which has no PyYAML, so it
|
|
# parses these files with tools/translations_lint.py. A hand parser that
|
|
# disagrees with the real loader is a check whose data cannot reach the failure
|
|
# it names, so the two are compared here - where both are available - rather
|
|
# than assumed to agree.
|
|
sys.path.insert(0, "/probe")
|
|
import translations_lint as _tl # noqa: E402
|
|
_mismatch = []
|
|
for lang in ("en", "nl"):
|
|
hand = _tl.parse_translation_file(f"/w/translations/{lang}.yaml")
|
|
real = {"configuration": TRANSLATIONS[lang].get("configuration", {})}
|
|
# SCHEMA_APP_TRANSLATIONS fills `description: None` for an absent one; the
|
|
# hand parser simply has no key. Normalise that ONE difference and nothing
|
|
# else, so a real disagreement cannot hide behind the normalisation.
|
|
def _norm(d):
|
|
return {k: {kk: vv for kk, vv in v.items() if vv is not None
|
|
and kk != "fields"}
|
|
| ({"fields": _norm(v["fields"])} if v.get("fields") else {})
|
|
for k, v in d.items()}
|
|
if _norm(hand.get("configuration", {})) != _norm(real["configuration"]):
|
|
a, b = _norm(hand.get("configuration", {})), _norm(real["configuration"])
|
|
_mismatch += [f"{lang}:{k}" for k in sorted(set(a) | set(b))
|
|
if a.get(k) != b.get(k)]
|
|
show("the hand parser CI uses (tools/translations_lint.py) returns EXACTLY "
|
|
"what Supervisor's own loader returns, for both files, key by key and "
|
|
f"string by string. Disagreements: {_mismatch}", not _mismatch)
|
|
_cfg_text = Path("/w/config.yaml").read_text(encoding="utf-8")
|
|
_probs = []
|
|
for lang in ("en", "nl"):
|
|
_probs += _tl.audit(
|
|
_cfg_text,
|
|
{"configuration": TRANSLATIONS[lang].get("configuration", {})}, lang)
|
|
show(f"...and the coverage audit is clean against the real loader's view too. "
|
|
f"Problems: {_probs}", not _probs)
|
|
|
|
print("\n== G. TEL-12: the SHAPE of a several-inverter PV option ==")
|
|
# ⚠️ WHY THIS SECTION EXISTS. TEL-12 had a real choice of surface and the two
|
|
# candidates differ on ONE question nobody could answer by reading: does
|
|
# Supervisor accept a stored STRING for a key whose schema has become a LIST?
|
|
# 0.11.0 shipped `pv_entity: str?`, the reference install HAS a string persisted
|
|
# in it, and an upgrade that refuses that string leaves an add-on which will not
|
|
# start - CFG-04 §B's trap, in the other direction. So it was measured.
|
|
#
|
|
# ⚠️ AND IT IS AN ISOLATED COUNTERFACTUAL LIKE §B AND §D. One key's schema
|
|
# changes at a time, from a baseline that is ACCEPTED on the line above, and each
|
|
# refusal is asserted ON THE KEY'S NAME rather than on `not ok` (habit 6).
|
|
PV_STORED = {"pv_entity": "sensor.pv_power"} # what 0.11.0 persisted
|
|
_pv_base = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(PV_STORED))
|
|
ok, out = validate(_pv_base)
|
|
show("the shipped defaults with a 0.11.0 `pv_entity` STRING on top are ACCEPTED "
|
|
"- the baseline every refusal below is varied from", ok,
|
|
f"pv_entity={out.get('pv_entity')!r} pv_entities={out.get('pv_entities')!r}"
|
|
if ok else out)
|
|
|
|
# --- OPTION 1: `pv_entity` becomes a list. THE ONE THAT LOSES. ---------------
|
|
_s_list = dict(schema, pv_entity=["str"])
|
|
ok_l, out_l = validate_with(_s_list, _pv_base)
|
|
show("⭐ THE MEASUREMENT THAT DECIDED THE SURFACE: with `pv_entity` declared as "
|
|
"a LIST, the string an 0.11.0 install has persisted is REFUSED, and the "
|
|
"refusal names the key - so that shape would leave the live add-on unable "
|
|
"to start on upgrade",
|
|
(not ok_l) and "Invalid list for option 'pv_entity'" in str(out_l), out_l)
|
|
# ⚠️ AND THE `[]` DEFAULT DOES NOT RESCUE IT, which is the half somebody would
|
|
# reach for. `_OPTIONS_MERGER`'s list strategy is OVERRIDE (CFG-04 §4), so the
|
|
# operator's string simply stands.
|
|
_d_list = dict(defaults, pv_entity=[])
|
|
_m_list = MERGER.merge(copy.deepcopy(_d_list), copy.deepcopy(PV_STORED))
|
|
ok_l2, out_l2 = validate_with(_s_list, _m_list)
|
|
show("...and a shipped `pv_entity: []` DEFAULT does not rescue it: deepmerge's "
|
|
"list strategy is `override`, so the operator's string survives the merge "
|
|
"and is refused anyway",
|
|
_m_list["pv_entity"] == "sensor.pv_power" and not ok_l2, out_l2)
|
|
ok_l3, out_l3 = validate_with(_s_list, dict(_pv_base, pv_entity=["sensor.a"]))
|
|
show("...(the list shape itself is fine - it is only the STORED STRING it "
|
|
"refuses, which is what makes this a migration and not a schema question)",
|
|
ok_l3, out_l3 if not ok_l3 else "a list value validates")
|
|
|
|
# --- OPTION 1b: one key, both shapes. ACCEPTED, AND STILL WRONG. -------------
|
|
# ⚠️ REPORTED EVEN THOUGH IT PASSES, because "no single spelling accepts both"
|
|
# would have been a FALSE reason to reject option 1 - and MUTATION.md's habit
|
|
# about inventing justifications applies to a probe as much as to a card.
|
|
_s_match = dict(schema, pv_entity="match(.*)?")
|
|
try:
|
|
SCHEMA_APP_CONFIG(dict(raw, schema=dict(raw["schema"],
|
|
pv_entity="match(.*)?")))
|
|
_manifest_ok = True
|
|
except vol.Invalid as e:
|
|
_manifest_ok, _me = False, str(e)
|
|
_ok_s, _ = validate_with(_s_match, _pv_base)
|
|
_ok_li, _ = validate_with(_s_match, dict(_pv_base, pv_entity=["a", "b"]))
|
|
_ok_num, _ = validate_with(_s_match, dict(_pv_base, pv_entity=417))
|
|
_ui_match = [e for e in UiOptions(None)(_s_match)
|
|
if e.get("name") == "pv_entity"]
|
|
show("⚠️ ONE SPELLING DOES ACCEPT BOTH - `match(.*)?` - AND IT IS REJECTED FOR "
|
|
"A DIFFERENT REASON, MEASURED HERE: it validates NOTHING. A string passes, "
|
|
"a list passes, and so does the integer 417; and the Configuration tab "
|
|
"renders it as a plain text box rather than a repeatable field, so an "
|
|
"operator cannot enter a second inverter in the UI at all",
|
|
_manifest_ok and _ok_s and _ok_li and _ok_num
|
|
and _ui_match and _ui_match[0].get("multiple") is not True,
|
|
f"string={_ok_s} list={_ok_li} integer-417={_ok_num} "
|
|
f"ui={json.dumps(_ui_match)}")
|
|
|
|
# --- OPTION 2: `pv_entities` beside `pv_entity`. WHAT SHIPPED. ---------------
|
|
show("⭐ THE SHIPPED SHAPE: an 0.11.0 blob - `pv_entity` a string, no "
|
|
"`pv_entities` key at all, because it did not exist when that blob was "
|
|
"written - validates against THIS manifest, with the new key defaulted to "
|
|
"the empty list. The live install upgrades without touching its options",
|
|
ok and out.get("pv_entity") == "sensor.pv_power"
|
|
and out.get("pv_entities") == [])
|
|
_three = ["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"]
|
|
ok3, out3 = validate(MERGER.merge(copy.deepcopy(defaults),
|
|
dict(PV_STORED, pv_entities=_three)))
|
|
show("...and a three-entity roster validates, per-entry, with both keys set - "
|
|
"which the add-on resolves by REPLACEMENT and says so on its status page",
|
|
ok3 and out3.get("pv_entities") == _three,
|
|
json.dumps(out3.get("pv_entities")) if ok3 else out3)
|
|
# ⚠️ §B's LOAD-BEARING MEASUREMENT, FOR THIS KEY. Exactly one key varies.
|
|
_no_pve = {k: v for k, v in defaults.items() if k != "pv_entities"}
|
|
ok_np, out_np = validate(_no_pve)
|
|
show("⭐ ...and the shipped `options:` block with ONLY `pv_entities` removed is "
|
|
"REFUSED, BY NAME - `Missing option 'pv_entities' in root`. That is why the "
|
|
"`pv_entities: []` default is load-bearing, exactly as `inverters: []` is: "
|
|
"Supervisor treats a nested list as required unless its element is an "
|
|
"OPTIONAL scalar, and `- str` is not one",
|
|
(not ok_np) and "Missing option 'pv_entities' in root" in str(out_np),
|
|
out_np)
|
|
_ui_pv = [e for e in UiOptions(None)(schema)
|
|
if e.get("name") in ("pv_entity", "pv_entities")]
|
|
show("...and the Configuration tab renders it as a REPEATABLE string field "
|
|
"(`multiple: true`), so an operator adds their second and third inverter "
|
|
"in the UI rather than in YAML",
|
|
[e["name"] for e in _ui_pv] == ["pv_entity", "pv_entities"]
|
|
and _ui_pv[1].get("multiple") is True
|
|
and _ui_pv[0].get("multiple") is not True, json.dumps(_ui_pv))
|
|
# --- the ROLLBACK direction, as §E does for `inverters` ----------------------
|
|
_flat = {k: v for k, v in schema.items() if k != "pv_entities"}
|
|
try:
|
|
_rolled = vol.Schema(vol.All(dict, AppOptions(
|
|
None, _flat, cfg["name"], "local_goodwe_controller")))(
|
|
MERGER.merge(copy.deepcopy(defaults),
|
|
dict(PV_STORED, pv_entities=_three)))
|
|
show("a DOWNGRADE to a schema without `pv_entities` drops the key with a "
|
|
"warning rather than refusing, and leaves `pv_entity` standing - so "
|
|
"rolling back to 0.11.0 returns the install to its single entity "
|
|
"rather than bricking it",
|
|
"pv_entities" not in _rolled
|
|
and _rolled.get("pv_entity") == "sensor.pv_power")
|
|
except vol.Invalid as e:
|
|
show("a DOWNGRADE to a schema without `pv_entities` drops the key", False,
|
|
str(e))
|
|
|
|
|
|
print("\n== H. TEL-14: the SHAPE of a per-entity PV sign option ==")
|
|
# Four candidate shapes, each against a 0.12.0 blob (`pv_entities` set, no new
|
|
# key). Transcript and decision: DECISIONS/TEL-14-pv-sign.md.
|
|
PV12 = {"pv_entity": "", "pv_entities": ["sensor.pv_inverter", "sensor.pv_meter"]}
|
|
_pv12 = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(PV12))
|
|
_nk = "pv_invert_entities"
|
|
|
|
|
|
def _ui_of(sch, name):
|
|
return [e for e in UiOptions(None)(sch) if e.get("name") == name]
|
|
|
|
|
|
# (a) a `-` prefix on a list item, under the shipped `- str`.
|
|
ok_a, out_a = validate(dict(_pv12, pv_entities=["sensor.pv_inverter",
|
|
"-sensor.pv_meter"]))
|
|
show("(a) PREFIX: `-sensor.pv_meter` validates under the shipped `- str` - "
|
|
"Supervisor does not look inside the string, so the sign would live in a "
|
|
"syntax that only the description text explains",
|
|
ok_a and out_a.get("pv_entities") == ["sensor.pv_inverter",
|
|
"-sensor.pv_meter"],
|
|
"" if ok_a else out_a)
|
|
|
|
# (b) a parallel list - THE SHIPPED SHAPE.
|
|
ok_b, out_b = validate(_pv12)
|
|
show("(b) ⭐ PARALLEL LIST, SHIPPED: the 0.12.0 blob (no `pv_invert_entities` "
|
|
"key) validates unchanged and the new key defaults to []",
|
|
ok_b and out_b.get(_nk) == []
|
|
and out_b.get("pv_entities") == PV12["pv_entities"],
|
|
"" if ok_b else out_b)
|
|
ok_b2, out_b2 = validate(dict(_pv12, **{_nk: ["sensor.pv_meter"]}))
|
|
show("...and one entity marked inverted validates",
|
|
ok_b2 and out_b2.get(_nk) == ["sensor.pv_meter"], "" if ok_b2 else out_b2)
|
|
ok_b3, out_b3 = validate({k: v for k, v in defaults.items() if k != _nk})
|
|
show("⭐ ...and the shipped `options:` block with ONLY `pv_invert_entities` "
|
|
"removed is REFUSED BY NAME - its `[]` default is load-bearing, as "
|
|
"`pv_entities: []` is",
|
|
(not ok_b3) and f"Missing option '{_nk}' in root" in str(out_b3), out_b3)
|
|
_ui_b = _ui_of(schema, _nk)
|
|
show("...and the Configuration tab renders it as a REPEATABLE string field "
|
|
"with its own top-level (translated) label",
|
|
len(_ui_b) == 1 and _ui_b[0].get("multiple") is True
|
|
and _ui_b[0].get("type") == "string", json.dumps(_ui_b))
|
|
|
|
# (c) a list of dicts - on `pv_entities` itself, and as a new key.
|
|
_s_dicts = dict(schema, pv_entities=[{"entity": "str", "invert": "bool?"}])
|
|
ok_c, out_c = validate_with(_s_dicts, _pv12)
|
|
show("(c) LIST OF DICTS ON `pv_entities`: the STRINGS a 0.12.0 install stored "
|
|
"are REFUSED - that shape would stop the add-on starting on upgrade",
|
|
(not ok_c) and "pv_entities" in str(out_c), out_c)
|
|
_s_src = dict(schema, pv_sources=[{"entity": "str", "invert": "bool?"}])
|
|
ok_c2, out_c2 = validate_with(_s_src, dict(_pv12, pv_sources=[]))
|
|
_ui_c = _ui_of(_s_src, "pv_sources")
|
|
show("(c2) ...as a THIRD key it validates, but renders as a type-schema "
|
|
"repeatable section whose per-entry keys reach the screen RAW "
|
|
"(`entity`, `invert`) on this frontend - see section F",
|
|
ok_c2 and len(_ui_c) == 1 and _ui_c[0].get("type") == "schema"
|
|
and _ui_c[0].get("multiple") is True,
|
|
json.dumps(_ui_c)[:200] if ok_c2 else out_c2)
|
|
|
|
# (d) one global bool.
|
|
_s_bool = dict(schema, pv_invert="bool")
|
|
ok_d, out_d = validate_with(_s_bool, dict(_pv12, pv_invert=False))
|
|
show("(d) GLOBAL `pv_invert: bool` validates too - it loses on SEMANTICS (one "
|
|
"sign for a mixed roster), not on the schema", ok_d, "" if ok_d else out_d)
|
|
|
|
|
|
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
|
|
# and still succeeds, and a transcript is only evidence if a wrong answer is
|
|
# distinguishable from a right one by something other than reading it.
|
|
print()
|
|
if FAILED:
|
|
print(f"== {len(FAILED)} CASE(S) FAILED ==")
|
|
for t in FAILED:
|
|
print(" - " + t)
|
|
sys.exit(1)
|
|
print("== all cases passed ==")
|