checks / unit (pull_request) Successful in 4m39s
CFG-04 (#45) merged first and moved the base. Two conflicts, both resolved as the union of the two sides: - app/main.py: an import-block collision. CFG-04 added validate_unit_ranges to the .validate import; SAFETY-30 added AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS and SignWatchdog to the .watchdog import. Neither side dropped anything, so the resolution keeps both lists whole. - CHANGELOG.md: two adjacent new entries under ## Unreleased. Both kept. Both sides also independently rewrote large parts of test_peakguard.py (60 and 47 insertions against the merge base) and git merged them textually, so the full suite was run on the MERGE RESULT rather than inferred from the two parents: 19 passed, 0 failed, twice. One caveat recorded rather than hidden: test_peakguard.py failed once during the first sequential full-suite run and then passed 10/10 in isolation and in two further full runs on this same tree. It passes on both parents too, so this is the pre-existing flakiness CFG-04 already disclosed, not a merge defect - it is timing- or load-sensitive. It sits on the required CI context and can therefore block an unrelated merge at random, which wants a card of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
779 lines
43 KiB
Python
779 lines
43 KiB
Python
"""Runnable check for the startup gate. `python3 test_validate.py`
|
|
|
|
Two halves, and the second is the one that matters. The first asserts that the
|
|
three refusable configurations are refused. The second asserts that everything
|
|
on validate.py's "deliberately not refused" list still STARTS - because the way
|
|
a validator dies is not by missing a fault, it is by refusing something harmless
|
|
until somebody deletes the call.
|
|
|
|
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
|
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
|
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
|
edit reverted inside one second is invisible to CPython's staleness test. That
|
|
is TEST-15, and it has already turned one live mutation into a false survivor.
|
|
Read MUTATION.md before you report a survivor.
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
|
|
from app.maintenance import MAINT_OPTION_DEFAULTS, MaintConfig
|
|
from app.validate import (
|
|
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused,
|
|
actuator_writers, refuse, validate_startup_config,
|
|
)
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
def base(**over):
|
|
"""A configuration that must start. Everything else is this, spoilt."""
|
|
# ⚠️ The maintenance rates are named EXPLICITLY, at the values config.yaml
|
|
# ships (MAINT-04). Under MAINT-04 that was load-bearing: with the key
|
|
# absent, rule 6 fell back to a 2500 W literal that against this 2000 W
|
|
# ceiling is a genuine refusal, so a base() without them was a config that
|
|
# does not start and every check below would have been measuring the wrong
|
|
# thing. CFG-03 closed that - the fallback is now config.yaml's own 2000 -
|
|
# so they are here to say out loud what this fixture asks for rather than to
|
|
# rescue it. The CFG-03 section at the foot of this file is what asserts
|
|
# that absent and present are now THE SAME NUMBER; do not delete these two
|
|
# keys, because a base() that agrees with the default by accident is a
|
|
# fixture that cannot tell you when the default moves.
|
|
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
|
"meter_source": "off",
|
|
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
|
|
o.update(over)
|
|
return o
|
|
|
|
|
|
def why(problems, needle):
|
|
return any(needle in p for p in problems)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the baseline must start, or nothing below means anything")
|
|
check("a good config with a 5000 W device is accepted",
|
|
validate_startup_config(base(), -5000, 5000) == [])
|
|
check("...and with no device reachable yet, too",
|
|
validate_startup_config(base(), None, None) == [])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("1. max_w against the device's own rating")
|
|
# ⚠️ THE ORIGINAL BUG. main.py used to log 'the device wins' and run anyway,
|
|
# with write_setpoint() clamping, so the configured ceiling was never the real
|
|
# one and the only evidence had scrolled off the log.
|
|
p = validate_startup_config(base(max_w=8000), -5000, 5000)
|
|
check("max_w above the device maximum is refused", len(p) == 1)
|
|
check("...and the message names the configured value", why(p, "8000"))
|
|
check("...and the device's value", why(p, "5000"))
|
|
check("...and says which one wins", why(p, "THE DEVICE WINS"))
|
|
check("max_w exactly at the device maximum is fine",
|
|
validate_startup_config(base(max_w=5000), -5000, 5000) == [])
|
|
check("max_w under it is fine",
|
|
validate_startup_config(base(max_w=2000), -5000, 5000) == [])
|
|
# The charge side is a separate rail and an asymmetric device is legal.
|
|
p = validate_startup_config(base(max_w=4000), -2500, 5000)
|
|
check("a max_w the device cannot take on the CHARGE side is refused too",
|
|
len(p) == 1 and why(p, "charge side"))
|
|
check("...naming the device's floor", why(p, "-2500"))
|
|
# ⚠️ Not guessed at: the entity may simply not have booted yet. run_control()
|
|
# re-runs this the moment the real rating arrives.
|
|
check("an unreachable device skips the range check rather than assuming",
|
|
validate_startup_config(base(max_w=8000), None, None) == [])
|
|
check("a non-numeric max_w is refused",
|
|
why(validate_startup_config(base(max_w="lots"), -5000, 5000), "not a number"))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 1b. SAFETY-20's AC4 decision, PINNED HERE RATHER THAN ONLY WRITTEN DOWN
|
|
# --------------------------------------------------------------------------- #
|
|
# AC4 asked whether a device rating that disarms SAFETY-18's freeze rail
|
|
# additionally warrants a refusal of its own, the way `sign_disagree_w >= max_w`
|
|
# is refused. THE ANSWER IS NO, AND THE REASON IS RULE 2 ABOVE: the configuration
|
|
# is `max_w` above the device rating, which rule 2 already refuses on the
|
|
# identical predicate. A rail-specific rule (`0.95 * max_w > dev_max`) would fire
|
|
# on a strict SUBSET of what rule 2 refuses, so it could never be the rule that
|
|
# stopped a start.
|
|
#
|
|
# ⚠️ THAT DECISION HAS A PREMISE, AND THIS IS THE PREMISE, EXECUTED. If rule 2 is
|
|
# ever softened to a warning - which is exactly what main.py used to do before
|
|
# CFG-01 - the reasoning recorded in validate.py's "deliberately not refused"
|
|
# list silently stops being true, and SAFETY-18 is left with only its runtime
|
|
# NOT COVERED row. Then somebody has to come back and re-decide AC4.
|
|
from app.freeze import RAIL_FRAC # noqa: E402 - imported here, beside its use
|
|
|
|
_DEV = 4700.0 # the ticket's inverter
|
|
_CEIL = 5000.0 # the ticket's configured ceiling
|
|
check("SAFETY-20's premise: a rating below max_w disarms the freeze rail, so "
|
|
f"{RAIL_FRAC:g} * max_w is unreachable on the ticket's install",
|
|
_DEV < RAIL_FRAC * _CEIL)
|
|
check("...and rule 2 REFUSES that whole configuration at startup, which is why "
|
|
"SAFETY-20 added no rule of its own (AC4)",
|
|
len(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV)) == 1)
|
|
check("...on the rating side specifically, not incidentally through some other "
|
|
"rule", why(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV),
|
|
"THE DEVICE WINS"))
|
|
check("...and the case the refusal CANNOT reach - the rating unread because "
|
|
"ESPHome was not up - still starts, which is precisely why SAFETY-20's "
|
|
"answer had to be a runtime status row and not a startup gate",
|
|
validate_startup_config(base(max_w=_CEIL), None, None) == [])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("2. an actuator to write to")
|
|
# A controller with no actuator: write_setpoint() returns on the blank entity,
|
|
# so no rating is read, no heartbeat is sent, layer 1 pins the inverter at 0 W,
|
|
# and the add-on looks perfectly healthy the whole time.
|
|
for blank in ("", " ", None):
|
|
p = validate_startup_config(base(setpoint_entity=blank), None, None)
|
|
check(f"setpoint_entity {blank!r} is refused", len(p) == 1)
|
|
check("...and the message says why it is dangerous rather than untidy",
|
|
why(validate_startup_config(base(setpoint_entity=""), None, None),
|
|
"heartbeat"))
|
|
# --------------------------------------------------------------------------- #
|
|
# specs §1.5: TWO WRITERS ON ONE ACTUATOR, AND WHAT ACTUALLY GRADES IT
|
|
# --------------------------------------------------------------------------- #
|
|
# ⚠️ WHAT USED TO BE HERE, AND WHY IT WAS NOT ENOUGH. This was one line:
|
|
#
|
|
# check("there is exactly one way to reach the actuator",
|
|
# ACTUATOR_WRITE_KEYS == ("setpoint_entity",))
|
|
#
|
|
# and validate.py's comment said the §1.5 non-goal was ENFORCED by construction -
|
|
# exactly one such key, so a second writer is not expressible in the options
|
|
# schema at all. Both were true. Both graded THE SHAPE OF THE OPTIONS SCHEMA
|
|
# rather than the writes, and the difference is not academic: the config below
|
|
# has TWO WRITERS ON ONE ACTUATOR and leaves that tuple's length at 1. The check
|
|
# stays green while the property it protects is gone, which is this repository's
|
|
# named failure mode - a check that observes something other than what it claims
|
|
# to observe.
|
|
#
|
|
# So the first thing this section does is DEMONSTRATE the old check passing on
|
|
# the hazardous config, rather than assert in prose that it would have.
|
|
_PER_INVERTER_HAZARD = base(inverters=[
|
|
{"id": "inverter-a", "setpoint_entity": "number.goodwe_setpoint"},
|
|
# ⚠️ INVERTER B DOES NOT EXIST. Nothing is measured from it here; it is a
|
|
# config fixture, and the only thing that matters about it is that its
|
|
# `setpoint_entity` NAMES THE SAME ENTITY as the scalar key above - which is
|
|
# the shape of the mistake, and the shape CFG-04 makes reachable.
|
|
{"id": "inverter-b", "setpoint_entity": "number.goodwe_setpoint"}])
|
|
|
|
check("⚠️ THE DEFECT, DEMONSTRATED: on a per-inverter config with two writers "
|
|
"aimed at ONE entity, the old proxy is still green - it counts option "
|
|
"KEYS, and there is still exactly one key called setpoint_entity. This "
|
|
"is the check that could not see the thing it was named after",
|
|
len(ACTUATOR_WRITE_KEYS) == 1)
|
|
|
|
_census = actuator_writers(_PER_INVERTER_HAZARD)
|
|
print(f" the same config, censused by writer: "
|
|
f"{ {k: len(v) for k, v in _census.items()} }")
|
|
check("...and the replacement SEES it, because it counts writers per ACTUATOR "
|
|
"rather than keys: three declarations, one entity",
|
|
list(_census) == ["number.goodwe_setpoint"]
|
|
and len(_census["number.goodwe_setpoint"]) == 3)
|
|
_hazard_problems = validate_startup_config(_PER_INVERTER_HAZARD, -5000, 5000)
|
|
check("AC 3: and the config is REFUSED, where today it starts",
|
|
why(_hazard_problems, "one actuator"))
|
|
check("...and the refusal names the actuator and every writer aimed at it, so "
|
|
"an operator can see WHICH declarations to remove rather than being told "
|
|
"only that something is wrong",
|
|
why(_hazard_problems, "number.goodwe_setpoint")
|
|
and why(_hazard_problems, "inverters[0].setpoint_entity")
|
|
and why(_hazard_problems, "inverters[1].setpoint_entity"))
|
|
|
|
# ⚠️ AND THE GATE MUST NOT REFUSE THE CONFIG CFG-04 IS ACTUALLY FOR. A validator
|
|
# that fires on the legitimate case gets switched off by the first person it
|
|
# inconveniences, and then none of this is enforced. Two inverters with two
|
|
# entities are two writers on TWO actuators, which is not the non-goal - it is
|
|
# the whole point of the per-inverter roster.
|
|
#
|
|
# ⚠️ THE ENTRIES BELOW GAINED `name` AND `max_w` WHEN CFG-04 LANDED, AND THE
|
|
# HAZARD FIXTURE ABOVE DELIBERATELY DID NOT. SAFETY-28 wrote both against a
|
|
# shape that did not exist yet and guessed `id`; CFG-04 chose `name`, and made
|
|
# `name`/`setpoint_entity`/`max_w` required of every entry (rule 9). The hazard
|
|
# fixture is left in the OLD guessed shape on purpose - the claim it makes is
|
|
# that `actuator_writers()` finds writers STRUCTURALLY, under a container and a
|
|
# key spelling this file was never told, and rewriting it into today's shape
|
|
# would quietly turn it into a check that the census knows about `inverters`.
|
|
# This one is the opposite claim - that a real, valid, two-inverter config is
|
|
# ACCEPTED - so it has to be a config the shipped validator actually accepts.
|
|
_PER_INVERTER_OK = {k: v for k, v in base().items() if k != "setpoint_entity"}
|
|
_PER_INVERTER_OK["inverters"] = [
|
|
{"name": "inverter-a", "setpoint_entity": "number.goodwe_setpoint",
|
|
"max_w": 2000},
|
|
# ⚠️ INVERTER B DOES NOT EXIST. 2000 W here is the SHIPPED default repeated,
|
|
# not a rating measured off a second device - SPIKE-04 measures that when
|
|
# there is one to measure. Nothing in this file depends on the number.
|
|
{"name": "inverter-b", "setpoint_entity": "number.goodwe_setpoint_b",
|
|
"max_w": 2000}]
|
|
check("two inverters with their own entities are ACCEPTED - one writer each, "
|
|
"no shared actuator, and that is CFG-04's whole purpose",
|
|
validate_startup_config(_PER_INVERTER_OK, -5000, 5000) == [])
|
|
check("...and today's single-inverter config is untouched: one writer, one "
|
|
"actuator, and the census finds it wherever it is declared",
|
|
validate_startup_config(base(), -5000, 5000) == []
|
|
and actuator_writers(base()) ==
|
|
{"number.goodwe_setpoint": ["setpoint_entity"]})
|
|
|
|
# The census is found STRUCTURALLY, not by looking for a container called
|
|
# `inverters` - CFG-04 has not chosen that name yet, and a census that guessed
|
|
# wrong would go green while two writers existed, which is the same defect one
|
|
# step along.
|
|
check("a writer nested under a container this file has never heard of is "
|
|
"still counted, because the walk is over the options TREE and not over a "
|
|
"list of blessed key names",
|
|
len(actuator_writers({"setpoint_entity": "number.x",
|
|
"whatever_cfg04_calls_it": {
|
|
"unit_b": {"setpoint_entity": "NUMBER.X"}}})
|
|
["number.x"]) == 2)
|
|
check("...and entity ids are folded to lower case, so a second writer cannot "
|
|
"be smuggled past the census on Number.X versus number.x",
|
|
why(validate_startup_config(base(inverters=[{"setpoint_entity":
|
|
"NUMBER.GOODWE_SETPOINT"}]),
|
|
-5000, 5000), "one actuator"))
|
|
# ⚠️ THIS CHECK CHANGED SHAPE IN CFG-04, AND THE REASON IS A DECISION RATHER
|
|
# THAN AN ADJUSTMENT. It used to assert that a blank per-inverter entity left
|
|
# the whole config ACCEPTED - "blank is how an operator leaves a slot
|
|
# unconfigured". CFG-04 decided that is not true of a ROSTER ENTRY: an entry
|
|
# with no `setpoint_entity` is a unit that `allocator.plan()` still gives a
|
|
# share of the site target to and that nothing can command, on a status page
|
|
# reporting a site at full strength. Rule 9 refuses it by name. The claim about
|
|
# the CENSUS is untouched and is now asserted directly on the census, where it
|
|
# always belonged - a blank contributes no writer, so it cannot manufacture a
|
|
# two-writer refusal out of two unconfigured slots.
|
|
check("a BLANK per-inverter entity is not a writer - the same rule the scalar "
|
|
"key gets, since write_setpoint() returns immediately on a blank entity",
|
|
actuator_writers(base(inverters=[{"setpoint_entity": " "},
|
|
{"setpoint_entity": ""}]))
|
|
== {"number.goodwe_setpoint": ["setpoint_entity"]})
|
|
check("...but the ENTRY is refused all the same, by rule 9 and not by the "
|
|
"census: a roster slot nothing can write still takes a share of the site "
|
|
"target every cycle and delivers none of it",
|
|
why(validate_startup_config(base(inverters=[{"name": "inverter-b",
|
|
"max_w": 2000,
|
|
"setpoint_entity": " "}]),
|
|
-5000, 5000), "has no setpoint_entity"))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("3. a meter that can never read")
|
|
# ⚠️ These predicates belong to p1.validate_meter_options(), which build_source()
|
|
# also uses. Asserted here to prove the gate actually consults it - one entry
|
|
# point, not two.
|
|
check("meter_source off asks nothing of the P1 keys",
|
|
validate_startup_config(base(meter_source="off"), -5000, 5000) == [])
|
|
check("homewizard_local with no p1_host is refused",
|
|
why(validate_startup_config(base(meter_source="homewizard_local"),
|
|
-5000, 5000), "p1_host"))
|
|
check("...and with a host it is accepted",
|
|
validate_startup_config(base(meter_source="homewizard_local",
|
|
p1_host="192.168.2.250"), -5000, 5000) == [])
|
|
check("a poll cadence at meter_max_age_s is refused at STARTUP, not just disabled",
|
|
why(validate_startup_config(base(meter_source="homewizard_local",
|
|
p1_host="m", meter_poll_s=30,
|
|
meter_max_age_s=30), -5000, 5000),
|
|
"meter_max_age_s"))
|
|
check("ha_signed with no p1_net_entity is refused",
|
|
why(validate_startup_config(base(meter_source="ha_signed"), -5000, 5000),
|
|
"p1_net_entity"))
|
|
check("a phase list disagreeing with meter_phases is refused",
|
|
why(validate_startup_config(base(meter_source="ha_signed",
|
|
p1_net_entity="sensor.n", meter_phases=3,
|
|
p1_phase_net_entities=["a", "b"]),
|
|
-5000, 5000), "phase-count"))
|
|
check("mqtt_p1 with no topic is refused",
|
|
why(validate_startup_config(base(meter_source="mqtt_p1"), -5000, 5000),
|
|
"meter_mqtt_topic"))
|
|
check("ha_dsmr with only half its entities is refused",
|
|
why(validate_startup_config(base(meter_source="ha_dsmr",
|
|
p1_import_entity="sensor.i"), -5000, 5000),
|
|
"p1_export_entity"))
|
|
check("a misspelt meter_source is refused rather than silently ignored",
|
|
why(validate_startup_config(base(meter_source="ha_signd"), -5000, 5000),
|
|
"is not one of"))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("4. meter_max_age_s against the firmware's compile-time veto")
|
|
# ⚠️ The schema allows int(5,300); the firmware's threshold is baked in at 30 s
|
|
# and wins silently. Same shape as the max_w case: a configured bound overridden
|
|
# by a lower layer, with nothing saying so.
|
|
hw = dict(meter_source="homewizard_local", p1_host="m")
|
|
p = validate_startup_config(base(**hw, meter_max_age_s=120), -5000, 5000)
|
|
check("a meter_max_age_s above the firmware veto is refused", len(p) == 1)
|
|
check("...naming the configured value", why(p, "120"))
|
|
check("...naming the firmware's value", why(p, "30"))
|
|
check("...and saying which one wins", why(p, "THE FIRMWARE WINS"))
|
|
check("the tested pair 30/30 is accepted",
|
|
validate_startup_config(base(**hw, meter_max_age_s=30), -5000, 5000) == [])
|
|
check("one second above it is refused",
|
|
validate_startup_config(base(**hw, meter_max_age_s=31), -5000, 5000) != [])
|
|
# Below the veto the add-on gives up FIRST, which is fail-safe and visible on
|
|
# its own status page. Only above is the silent case.
|
|
check("below the veto is fine - the add-on being stricter is not a conflict",
|
|
validate_startup_config(base(**hw, meter_max_age_s=10,
|
|
meter_poll_s=4), -5000, 5000) == [])
|
|
# ⚠️ Scope: meter_max_age_s is read by nothing but P1Ingest, and the veto keys
|
|
# off a sensor only published when ingestion runs. With the meter off it governs
|
|
# nothing, so refusing would be pure trivia.
|
|
check("with meter_source off the number governs nothing and is not refused",
|
|
validate_startup_config(base(meter_source="off", meter_max_age_s=300),
|
|
-5000, 5000) == [])
|
|
check("the constant is the firmware's, not a rounded guess",
|
|
FIRMWARE_INGEST_VETO_S == 30.0)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("6. a maintenance charge the ceiling can actually accommodate (MAINT-04)")
|
|
# ⚠️ THE SHIPPED BUG. config.yaml shipped max_w 2000 against
|
|
# maintenance_charge_w 2500, so the cheap-window shaping asked for hi=-2500
|
|
# against the supervised lo=-2000 - lo > hi, arbiter clause 3, 0 W. Maintenance
|
|
# silently never charged in a cheap window on a default install.
|
|
p = validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
|
|
-5000, 5000)
|
|
check("maintenance_charge_w above max_w is refused", len(p) == 1)
|
|
check("...and the message names the charge rate", why(p, "2500"))
|
|
check("...and the ceiling", why(p, "2000"))
|
|
check("...and says which one wins", why(p, "THE CEILING WINS"))
|
|
# ⚠️ Equality is REACHABLE, not a near miss: lo=-2000, hi=-2000 is the single
|
|
# point -2000 W. Refusing it would be exactly the trivia that gets a validator
|
|
# deleted, and it is the boundary the rule is written `>` for.
|
|
check("maintenance_charge_w exactly at max_w starts",
|
|
validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
|
|
-5000, 5000) == [])
|
|
check("below it starts",
|
|
validate_startup_config(base(max_w=2000, maintenance_charge_w=1500),
|
|
-5000, 5000) == [])
|
|
# Not scoped to maintenance_enabled: the pair is contradictory either way, and
|
|
# scoping would leave the trap armed for the one action - ticking the box - that
|
|
# cannot be expected to change what a charge rate means.
|
|
check("...and it is refused with maintenance_enabled off, as shipped",
|
|
why(validate_startup_config(
|
|
base(max_w=2000, maintenance_charge_w=2500,
|
|
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
|
|
|
|
# ⚠️ THE SIBLING, CHECKED RATHER THAN ASSUMED. The shaping is NOT symmetric:
|
|
# drain files Claim.set at P_MAINTENANCE and owns the actuator, so the ceiling
|
|
# CLAMPS it (+2000 W) instead of contradicting it, and the arbiter's explain()
|
|
# names the clamp every cycle. Loud, and it still moves energy - so warned, not
|
|
# refused. If someone later "fixes the asymmetry" by making this a refusal,
|
|
# this check goes red and they have to justify it.
|
|
check("maintenance_discharge_w above max_w WARNS but starts",
|
|
validate_startup_config(base(max_w=2000, maintenance_discharge_w=2500),
|
|
-5000, 5000) == [])
|
|
|
|
# The demonstration the rule is derived from, asserted rather than described.
|
|
from app.arbiter import ( # noqa: E402
|
|
Claim, P_LOOP, P_MAINTENANCE, P_MAINT_SHAPE, P_SAFETY, resolve,
|
|
)
|
|
|
|
def _charge(max_w, charge_w):
|
|
return resolve([Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"),
|
|
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-charge_w,
|
|
reason="cheap-window charge"),
|
|
Claim.set("loop", P_LOOP, 500.0, "grid")])
|
|
|
|
def _drain(max_w, disch_w):
|
|
return resolve([Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"),
|
|
Claim.set("maintenance", P_MAINTENANCE, disch_w, "drain"),
|
|
Claim.set("loop", P_LOOP, 500.0, "grid")])
|
|
|
|
r = _charge(2000, 2500)
|
|
check("arbiter: the refused pair really does emit 0 W",
|
|
r.target_w == 0.0 and r.contradiction)
|
|
r = _charge(2000, 2000)
|
|
check("arbiter: the permitted equality really does charge",
|
|
r.target_w == -2000.0 and not r.contradiction)
|
|
r = _charge(3000, 2500)
|
|
check("arbiter: raising max_w above the rate charges at the rate",
|
|
r.target_w == -2500.0 and not r.contradiction)
|
|
r = _drain(2000, 2500)
|
|
check("arbiter: the drain SET is clamped, not contradicted",
|
|
r.target_w == 2000.0 and not r.contradiction)
|
|
check("...and the clamp names itself, which is why it is not refused",
|
|
"supervised" in (r.bound_by or "") and "limited by" in r.explain())
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
|
|
# ⚠️ Shipping a default the validator refuses would be worse than the bug it
|
|
# refuses. Read config.yaml itself rather than a transcription of it - a
|
|
# transcription is what lets the two drift apart silently. Regex, not PyYAML:
|
|
# the add-on image has no PyYAML (Dockerfile installs py3-aiohttp/py3-paho-mqtt
|
|
# only) and a check that skips on ImportError is not a check.
|
|
import os # noqa: E402
|
|
import re # noqa: E402
|
|
|
|
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
|
with open(_CFG, encoding="utf-8") as _fh:
|
|
_txt = _fh.read()
|
|
_shipped = {k: float(v) for k, v in re.findall(
|
|
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w): "
|
|
r"(-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
|
check("config.yaml really was read (all four defaults found)",
|
|
set(_shipped) == {"max_w", "maintenance_charge_w",
|
|
"maintenance_discharge_w", "sign_disagree_w"})
|
|
check(f"the shipped defaults are accepted {_shipped}",
|
|
validate_startup_config(base(**_shipped), -5000, 5000) == [])
|
|
check("...and the shipped charge rate is at or under the shipped ceiling",
|
|
_shipped["maintenance_charge_w"] <= _shipped["max_w"])
|
|
check("...and the shipped discharge rate too, so it warns about nothing",
|
|
_shipped["maintenance_discharge_w"] <= _shipped["max_w"])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the shipped meter_freeze_s, pinned (TEST-06 / AC3)")
|
|
# ⚠️ WHY A NUMBER GETS ITS OWN CHECK. `meter_freeze_s: 300` is the threshold
|
|
# SAFETY-18's entire argument is about, and before this it was pinned NOWHERE:
|
|
# 300 -> 3600 left test_freeze.py AND this file green, because the schema
|
|
# accepts int(60,3600) and no check ever read the shipped value. The schema
|
|
# bounds what is CONFIGURABLE; this pins what is SHIPPED, which is a different
|
|
# question and the one the docs answer.
|
|
#
|
|
# Read by regex like _shipped above, for the same reason: no PyYAML in the
|
|
# image, and a check that skips on ImportError is not a check.
|
|
_freeze_s = [float(v) for v in re.findall(
|
|
r"^ meter_freeze_s: (\d+(?:\.\d+)?)\s*$", _txt, re.M)]
|
|
_freeze_schema = re.findall(r"^ meter_freeze_s: int\((\d+),(\d+)\)\s*$",
|
|
_txt, re.M)
|
|
check(f"config.yaml carries exactly one meter_freeze_s default {_freeze_s}",
|
|
len(_freeze_s) == 1)
|
|
check("...and exactly one schema range for it", len(_freeze_schema) == 1)
|
|
_hold_s = _freeze_s[0] if _freeze_s else None
|
|
_lo, _hi = ((float(_freeze_schema[0][0]), float(_freeze_schema[0][1]))
|
|
if _freeze_schema else (None, None))
|
|
check("the shipped meter_freeze_s is still the measured 300 s that freeze.py, "
|
|
"config.yaml's own comment and test_freeze.py are all written at",
|
|
_hold_s == 300.0)
|
|
check(f"...and it is inside its own schema range ({_lo}-{_hi} s)",
|
|
None not in (_lo, _hi, _hold_s) and _lo <= _hold_s <= _hi)
|
|
|
|
# The two properties the number has to have, DERIVED rather than restated - so
|
|
# they go red on a value that is merely schema-legal. Both come straight from
|
|
# config.yaml's own comment on the key.
|
|
#
|
|
# upper: the alert must land inside the 900 s quarter that bills the capacity
|
|
# tariff, or the notification arrives after the money is spent;
|
|
# lower: the slowest a HEALTHY meter can tick a 1 Wh register and still look
|
|
# unchanged for hold_s is 3600/hold_s W, and that must stay inside the
|
|
# deadband or the conjunction's two terms stop being mutually
|
|
# exclusive (freeze.py, "WHERE hold_s COMES FROM").
|
|
_QUARTER_S = 900.0
|
|
_shipped_deadband = [float(v) for v in re.findall(
|
|
r"^ deadband_w: (\d+(?:\.\d+)?)\s*$", _txt, re.M)]
|
|
check(f"config.yaml carries exactly one deadband_w default {_shipped_deadband}",
|
|
len(_shipped_deadband) == 1)
|
|
_db = _shipped_deadband[0] if _shipped_deadband else None
|
|
_slowest_healthy_w = 3600.0 / _hold_s if _hold_s else None
|
|
print(f" shipped meter_freeze_s {_hold_s} s: alert inside the "
|
|
f"{_QUARTER_S:.0f} s billing quarter, and a healthy meter slow enough to "
|
|
f"look unchanged for it is {_slowest_healthy_w} W against a "
|
|
f"{_db} W deadband")
|
|
check(f"...the alarm still lands inside the {_QUARTER_S:.0f} s billing quarter",
|
|
_hold_s is not None and _hold_s < _QUARTER_S)
|
|
check("...and a healthy meter that looks unchanged for it is still inside the "
|
|
"deadband, so the two terms stay mutually exclusive",
|
|
_slowest_healthy_w is not None and _db is not None
|
|
and _slowest_healthy_w < _db)
|
|
|
|
# ⚠️ AND THE RANGE'S OWN BOUNDS. "300 sits inside the range" is worth nothing
|
|
# while the range itself is unpinned: widened to `int(1,86400)` an operator can
|
|
# configure `meter_freeze_s: 86400` and the guard is dead for a day at a time,
|
|
# with every check in this repo still green. That mutation survived the first
|
|
# cut of this PR and is what this block closes. Both numbers are argued for in
|
|
# config.yaml's own comment and in freeze.py, and each is re-derived here:
|
|
#
|
|
# 60 s the floor freeze.py calls NOT A RECOMMENDATION - there a healthy
|
|
# meter can look unchanged at 60 W, four times the shipped deadband, so
|
|
# the conjunction's two terms stop being mutually exclusive and only
|
|
# the margin is left;
|
|
# 3600 s the ceiling - already four times the billing quarter, so the top of
|
|
# the range has given up detecting a freeze inside the quarter that
|
|
# bills it. It is the last bound between a supported configuration and
|
|
# a disabled guard, and moving it is a decision rather than a tidy-up.
|
|
print(f" schema range {_lo}-{_hi} s: at the floor a healthy meter that looks "
|
|
f"unchanged is "
|
|
+ (f"{3600.0 / _lo:.0f} W" if _lo else "?")
|
|
+ f" against a {_db} W deadband; the ceiling is "
|
|
+ (f"{_hi / _QUARTER_S:.1f}x" if _hi else "?")
|
|
+ " the billing quarter")
|
|
check(f"the schema range itself is still the argued 60-3600 s (found "
|
|
f"{_lo}-{_hi})", (_lo, _hi) == (60.0, 3600.0))
|
|
check("...a floor that gives up the mutual exclusion and keeps only the "
|
|
"margin, which is why config.yaml calls it not a recommendation",
|
|
None not in (_lo, _db) and 3600.0 / _lo > _db)
|
|
check("...and a ceiling already past the billing quarter, so no value the "
|
|
"schema still accepts can alarm inside the quarter that bills it",
|
|
_hi is not None and _hi > _QUARTER_S)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("max_w must be a positive magnitude")
|
|
# Schema-blocked on a real install, but validate_startup_config() is also
|
|
# sim/inject.py's entry point and can be handed any dict at all.
|
|
for bad in (0, -1, -2000):
|
|
p = validate_startup_config(base(max_w=bad), -5000, 5000)
|
|
check(f"max_w {bad} is refused", len(p) == 1 and why(p, "must be positive"))
|
|
check("...and the message explains it would read as a trip, not a mistake",
|
|
why(validate_startup_config(base(max_w=0), -5000, 5000), "contradiction"))
|
|
# ⚠️ SAFETY-02 changed what this case has to say, and the change is real rather
|
|
# than bookkeeping: with a 1 W ceiling the default 300 W sign-disagreement
|
|
# threshold is unreachable, so rule 5 refuses - correctly, because the watchdog
|
|
# would report itself armed while being incapable of firing. What this check is
|
|
# FOR is "a positive max_w is not itself a refusal", so it now names a sign
|
|
# threshold the ceiling can actually reach, and the interaction gets its own
|
|
# check below.
|
|
check("the smallest sane max_w still starts",
|
|
validate_startup_config(base(max_w=1, sign_disagree_w=0.5,
|
|
unit_sign_disagree_w=0.5,
|
|
maintenance_charge_w=1), -5000, 5000) == [])
|
|
check("...but that same 1 W ceiling with the DEFAULT sign threshold is refused",
|
|
why(validate_startup_config(base(max_w=1), -5000, 5000), "never fire"))
|
|
# ⚠️ SAFETY-30 ADDED A SECOND WAY TO DISARM A SIGN WATCHDOG BY CONFIGURATION,
|
|
# and the fixture above now has to name BOTH thresholds - which is the check
|
|
# doing its job rather than a bookkeeping edit. `unit_sign_disagree_w` bounds a
|
|
# unit's own SHARE, which the same `supervised` claim holds at +-max_w, so at or
|
|
# above the ceiling it is unsatisfiable by construction exactly as
|
|
# `sign_disagree_w` is. Refused even at the shipped roster of one, where this
|
|
# watchdog is not armed: a config that cannot work is refused when it is
|
|
# written, not on the day somebody adds a second inverter.
|
|
check("...and the per-inverter threshold has the same rule, named separately "
|
|
"so an operator is told WHICH watchdog they disarmed",
|
|
why(validate_startup_config(base(max_w=1, sign_disagree_w=0.5,
|
|
maintenance_charge_w=1), -5000, 5000),
|
|
"unit_sign_disagree_w"))
|
|
check("...and one watt under that ceiling is reachable, so accepted",
|
|
validate_startup_config(base(max_w=2000, unit_sign_disagree_w=1999),
|
|
-5000, 5000) == [])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("what must NOT be refused - a validator that fires on trivia gets deleted")
|
|
# DOCS.md: the arbiter's `supervised` limit still binds the wire to +-max_w, so
|
|
# the only cost of the extra headroom is unwind latency after saturation.
|
|
check("integrator_max_w above max_w warns but starts",
|
|
validate_startup_config(base(max_w=2000, integrator_max_w=9000),
|
|
-5000, 5000) == [])
|
|
check("integrator_max_w 0 (follow max_w) starts",
|
|
validate_startup_config(base(integrator_max_w=0), -5000, 5000) == [])
|
|
# Fail-safe AND already a red NOT READY banner naming the entity: a refusal on
|
|
# top of that adds nothing an operator can act on.
|
|
check("a missing soc_entity is left to the readiness banner",
|
|
validate_startup_config(base(soc_entity=""), -5000, 5000) == [])
|
|
check("a missing batt_entity likewise",
|
|
validate_startup_config(base(batt_entity=""), -5000, 5000) == [])
|
|
# The transient case. ESPHome is routinely not up when the add-on boots, and
|
|
# refusing here would turn a device that arrives late into a restart loop.
|
|
check("a setpoint entity that is configured but not yet found starts",
|
|
validate_startup_config(base(), None, None) == [])
|
|
check("meter_poll_s with thin but non-zero headroom starts",
|
|
validate_startup_config(base(meter_source="homewizard_local", p1_host="m",
|
|
meter_poll_s=20, meter_max_age_s=30),
|
|
-5000, 5000) == [])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the refusal must be legible as a refusal, not as a crash")
|
|
p = validate_startup_config(base(max_w=8000, setpoint_entity=""), -5000, 5000)
|
|
check("several problems are all reported, not just the first", len(p) >= 1)
|
|
refuse(p) # prints the banner; the eye is the assertion for its wording
|
|
err = ConfigRefused(p)
|
|
check("ConfigRefused carries every problem", err.problems == p)
|
|
check("...and str() names one of them", str(err) == p[0])
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("the LATE refusal, when the device only shows up after the gate has run")
|
|
# ⚠️ This path had no test at all and no live demonstration - the review's
|
|
# catch. ESPHome is routinely not up when the add-on boots, so hass.limits()
|
|
# returns (None, None), the range checks are skipped, and run_control() is the
|
|
# first place the device's real rating is ever known. Everything below is about
|
|
# what happens when it turns out to conflict.
|
|
from app.main import Controller # noqa: E402
|
|
|
|
|
|
class _LateHass:
|
|
"""Reports the range only run_control() gets to see."""
|
|
|
|
def __init__(self, lo, hi):
|
|
self.lo, self.hi = lo, hi
|
|
self.writes = []
|
|
|
|
async def limits(self, entity):
|
|
return (self.lo, self.hi, 1.0)
|
|
|
|
async def number(self, *a, **k):
|
|
return 0.0
|
|
|
|
async def set_number(self, entity, value):
|
|
self.writes.append(value)
|
|
return True
|
|
|
|
|
|
class _Store2:
|
|
data = {}
|
|
|
|
def set(self, *a):
|
|
pass
|
|
|
|
|
|
class _Pub2:
|
|
enabled = False
|
|
|
|
def publish(self, *a, **k):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
def late(opts_over, lo, hi):
|
|
"""Drive run_control() against a device that only reports (lo, hi) now.
|
|
|
|
⚠️ Returns whether run_control() EXITED ON ITS OWN. That is the assertion
|
|
that matters: on a refusal it must return without entering the while loop,
|
|
and on a clean config it must not return at all. So the clean case is stopped
|
|
from outside after it has had time to write, rather than waited on - a
|
|
wait_for() here would just time out, which is the correct behaviour dressed
|
|
up as a failure.
|
|
"""
|
|
async def go():
|
|
stop = asyncio.Event()
|
|
hass = _LateHass(lo, hi)
|
|
# A device narrower than the shipped 5000 W schema ceiling is exactly
|
|
# the case that cannot be reached through the Supervisor on the live
|
|
# install, so it is reached here instead.
|
|
c = Controller(base(**opts_over), hass, _Store2(), _Pub2(), stop=stop)
|
|
c.auto = True
|
|
task = asyncio.create_task(c.run_control())
|
|
for _ in range(30): # up to ~3 s
|
|
if task.done():
|
|
break
|
|
await asyncio.sleep(0.1)
|
|
exited = task.done()
|
|
if not exited:
|
|
c.stopping = True
|
|
await asyncio.wait_for(task, timeout=5)
|
|
return c, stop, hass, exited
|
|
return asyncio.run(go())
|
|
|
|
c, stop, hass, exited = late({"max_w": 5000}, -3600.0, 3600.0)
|
|
check("a device narrower than max_w, discovered late, is refused",
|
|
c.refused is not None and len(c.refused) == 1)
|
|
check("...naming both values", why(c.refused, "5000") and why(c.refused, "3600"))
|
|
check("...signalling amain to wind down", stop.is_set())
|
|
check("...and returning instead of looping forever", exited)
|
|
# The whole point: run_control() must not fall through into the while loop. A
|
|
# single write here would mean it commanded the inverter under a config it had
|
|
# just declared unusable.
|
|
check("...having never entered the control loop, so it wrote nothing",
|
|
hass.writes == [])
|
|
check("...and it is recorded in the event log an operator reads",
|
|
any("REFUSED" in e for e in c.events))
|
|
|
|
# The same path when there is no conflict: it must NOT refuse, and it must
|
|
# actually regulate - asserted by a write reaching the device, not by absence.
|
|
c2, stop2, hass2, exited2 = late({"max_w": 2000}, -5000.0, 5000.0)
|
|
check("a late device that agrees does not trip the re-check", c2.refused is None)
|
|
check("...and the control loop runs, writing to the device", hass2.writes != [])
|
|
check("...and does not signal a shutdown", not stop2.is_set() and not exited2)
|
|
|
|
# ⚠️ THE FOOTGUN THE REVIEW FOUND. `stop` used to default to None, so this
|
|
# construction was legal and the refusal path then returned into a live process
|
|
# with no control loop, no exit and no further output.
|
|
try:
|
|
Controller(base(), _LateHass(-5000.0, 5000.0), _Store2(), _Pub2())
|
|
check("a Controller with no stop Event is rejected at construction", False)
|
|
except TypeError:
|
|
check("a Controller with no stop Event is rejected at construction", True)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("CFG-03: the key ABSENT is the SAME NUMBER everywhere, through real code")
|
|
# ⚠️ WHAT THIS REPLACES, AND WHY IT IS THE OPPOSITE ASSERTION. MAINT-04 left a
|
|
# TRIPWIRE here whose PASSING CONDITION WAS THE BUG: it asserted that
|
|
# config.yaml said 2000 while main.py's two `opts.get(..., 2500)` fallbacks and
|
|
# MaintConfig's dataclass default said 2500, and it went red if EITHER side
|
|
# moved - including when somebody closed the divergence. That check has been
|
|
# DELETED, as it and validate.py's ⚠️ block both instructed, and this is what
|
|
# stands in its place: the same four consumers, exercised the same way, now
|
|
# agreeing.
|
|
#
|
|
# The four are the four the defect ran through. The validator ACCEPTS a dict
|
|
# with the key missing; main.py BUILDS a MaintConfig from that same dict; the
|
|
# dataclass answers on its own with no dict at all; and the arbiter is what
|
|
# turns their disagreement into a number an operator can see. Any three of them
|
|
# agreeing was never the property that mattered.
|
|
_absent = {k: v for k, v in base().items()
|
|
if not k.startswith("maintenance_")}
|
|
_ship_charge = _shipped["maintenance_charge_w"]
|
|
_ship_drain = _shipped["maintenance_discharge_w"]
|
|
|
|
check("the validator still ACCEPTS a dict with every maintenance key missing - "
|
|
"the fixture is the reachable one, not a refusal in disguise",
|
|
validate_startup_config(_absent, -5000, 5000) == [])
|
|
|
|
# 1. the mirror against the shipped file. Read, not transcribed: a transcription
|
|
# is the thing that drifted.
|
|
check(f"the ONE in-code mirror equals what config.yaml ships "
|
|
f"(charge {MAINT_OPTION_DEFAULTS['maintenance_charge_w']}, "
|
|
f"shipped {_ship_charge:.0f})",
|
|
float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"]) == _ship_charge
|
|
and float(MAINT_OPTION_DEFAULTS["maintenance_discharge_w"]) == _ship_drain)
|
|
|
|
# 2. MaintConfig on its own - the one that bites when main.py is bypassed
|
|
# entirely, which is sim/inject.py and most of this suite.
|
|
check(f"MaintConfig() answers the shipped number, not a third one "
|
|
f"({MaintConfig().charge_w:.0f} W)",
|
|
MaintConfig().charge_w == _ship_charge
|
|
and MaintConfig().discharge_w == _ship_drain)
|
|
|
|
# 3. main.py's own path, through a real Controller rather than through a regex
|
|
# over its source. A source scan can only say the literal is gone; this says
|
|
# the object an operator's install actually runs on holds the right number.
|
|
_ctl_absent = Controller(_absent, None, _Store2(), _Pub2(), stop=asyncio.Event())
|
|
print(f" a Controller built from a dict with NO maintenance key: "
|
|
f"charge_w={_ctl_absent.maint.cfg.charge_w:.0f} W, "
|
|
f"discharge_w={_ctl_absent.maint.cfg.discharge_w:.0f} W")
|
|
check("main.py builds the shipped number from a dict that does not contain it",
|
|
_ctl_absent.maint.cfg.charge_w == _ship_charge
|
|
and _ctl_absent.maint.cfg.discharge_w == _ship_drain)
|
|
# ...and it is the SAME number when the operator supplies it explicitly, which
|
|
# is what "the default is not a special case" means.
|
|
_ctl_named = Controller(base(), None, _Store2(), _Pub2(), stop=asyncio.Event())
|
|
check("...and an explicit key builds the identical config, so absent and "
|
|
"present are not two code paths",
|
|
_ctl_named.maint.cfg == _ctl_absent.maint.cfg)
|
|
|
|
# 4. the arbiter - the consequence itself. Under MAINT-04 this pair was
|
|
# (2000, 2500) and resolved to 0 W with contradiction=True; that was the
|
|
# ACCEPTED-then-contradicted hole. The rates come out of the built config,
|
|
# not out of a literal, so if the single source moves this moves with it.
|
|
_r_ok = _charge(float(_absent["max_w"]), _ctl_absent.maint.cfg.charge_w)
|
|
print(f" arbiter with that config: {_r_ok.explain()}")
|
|
check("the arbiter CHARGES instead of contradicting itself - the hole MAINT-04 "
|
|
"pinned open is closed at the only place it was ever reachable",
|
|
_r_ok.target_w == -_ship_charge and not _r_ok.contradiction)
|
|
_r_drain = _drain(float(_absent["max_w"]), _ctl_absent.maint.cfg.discharge_w)
|
|
check("...and the drain SET is not clamped either, so the twin key the card "
|
|
"did not name is closed too",
|
|
_r_drain.target_w == _ship_drain and not _r_drain.contradiction)
|
|
|
|
# 5. nothing typed a number behind the mirror's back. Rule 6's fallback and
|
|
# main.py's are the two sites the divergence lived at, so they are the two
|
|
# sites a scan is worth running over.
|
|
_srcs = {name: open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"app", name), encoding="utf-8").read()
|
|
for name in ("main.py", "validate.py", "maintenance.py")}
|
|
_literals = {name: re.findall(
|
|
r'"(maintenance_[a-z_]+)"\s*,\s*(-?\d+(?:\.\d+)?)\s*[,)]', src)
|
|
for name, src in _srcs.items()}
|
|
check(f"no numeric fallback for any maintenance_* key survives in main.py, "
|
|
f"validate.py or maintenance.py {_literals}",
|
|
not any(_literals.values()))
|
|
check("...and MaintConfig's dataclass defaults carry no numeric literal either "
|
|
"- they are indexed out of the mirror",
|
|
not re.search(r"^\s*(charge_w|discharge_w):\s*float\s*=\s*-?\d",
|
|
_srcs["maintenance.py"], re.M))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
|
sys.exit(1)
|
|
print("all checks passed")
|