diff --git a/docs/contracts/web-ui-v0.1.md b/docs/contracts/web-ui-v0.1.md index 5bbbd1b..1dd06c0 100644 --- a/docs/contracts/web-ui-v0.1.md +++ b/docs/contracts/web-ui-v0.1.md @@ -326,6 +326,160 @@ freshness or alarm state. - `raw_payload` is never rendered. - Device-provided values and error details are HTML-escaped. +## GUI automation test-ID contract + +`data-testid` is the stable machine-facing interface for external black-box +GUI tests. Tests use it instead of CSS classes, DOM position, visible wording, +color, language or responsive layout. It does not replace semantic HTML, +accessible names or other accessibility attributes. + +### Syntax, identity and uniqueness + +Every test ID consists only of ASCII letters, digits, `.`, `_`, `%` and `-`. +Static registry segments are lowercase kebab case. A dynamic barcode component +preserves the original barcode bytes: every UTF-8 byte outside ASCII letters, +digits, `-`, `_` and `.` is encoded as uppercase `%HH`; literal `%` is `%25`. +This encoding is reversible and collision-free. Device text is never inserted +unencoded into a test ID. + +Module IDs use encoded barcode, never position. Cell IDs use encoded barcode +and canonical zero-based parser index `0..14`. Position IDs use canonical +unsigned decimal only for topology entries. Every `data-testid` is unique +within a full rendered document and within a standalone HTMX fragment. + +The same semantic element has the same ID in full-page and fragment responses, +after HTMX replacement, across responsive layouts, and after a module move. +Current, stale, invalid and unavailable values retain their selector. A value +is rendered as `N/A` or accompanied by its status; its test ID is not removed +merely because the measurement is unavailable. Conditional error and MQTT +detail IDs exist exactly when the corresponding sanitized value exists. + +### Required rack registry + +Static rack IDs are: + +```text +rack-page +rack-current-state +rack-health +service-status +service-errors +service-error- +mqtt-status +mqtt-last-connected-at +mqtt-last-disconnected-at +mqtt-consecutive-failures +mqtt-error +rack-age +rack-snapshot-at +rack-soc +rack-voltage +rack-current +rack-power +rack-cell-voltage-delta +rack-present-modules +rack-limits +rack-average-cell-voltage +rack-cell-voltage-range +rack-temperature-range +rack-average-temperature +rack-charge-voltage-limit +rack-discharge-voltage-limit +rack-charge-current-limit +rack-discharge-current-limit +rack-soh +inventory-status +module-overview +topology +topology-position- +cell-voltage-heatmap +cell-voltage-heatmap-unavailable +cell-voltage-heatmap-legend +cell-voltage-absolute-legend +``` + +Per-module rack IDs, where `` is the encoded barcode, are: + +```text +module--card +module--barcode +module--position +module--soc +module--state +module--voltage-summary +module--cell-voltage-delta +module--detail-status +module--cells-status +module--heatmap-row +module--heatmap-link +module--heatmap-status +module--voltage +module--cell-average +module--cell--heatmap +module--cell--heatmap-voltage +module--cell--heatmap-deviation +``` + +The heatmap tile itself exposes absolute-voltage state through its contractual +`data-absolute-state` attribute in every state, including normal, stale, +invalid and unavailable. + +### Required module-detail registry + +For encoded ``: + +```text +module--page +module--current-state +module--header +module--barcode +module--position +module--present +module--snapshot-at +module--soc +module--state +module--voltage +module--current +module--temperature +module--cell-voltage-delta +module--cell-capture +module--detail-status +module--cells-status +module--errors +module--error- +module--identity +module--identity- +module--freshness +module--freshness- +module--cells +module--cell-table-status +module--cells-unavailable +module--cell--row +module--cell--voltage +module--cell--current +module--cell--temperature +module--cell--soc +module--cell--coulomb +module--cell--balancing +module--cell--base-status +module--cell--voltage-status +module--cell--current-status +module--cell--temperature-status +``` + +Modeled identity fields are `manufacturer`, `model`, `board`, +`main-firmware`, `software`, `boot`, `release-date` and `specification`. +Freshness fields are `detail-age`, `detail-received-at`, `cell-age`, +`cells-received-at`, `cell-minimum-voltage` and `cell-maximum-voltage`. + +### Compatibility and change management + +Adding a new test ID is backward compatible. Renaming, repurposing or removing +a required ID is a contract change: update this registry, the external +acceptance tests and the documented interface version in the same coordinated +release. An existing ID must never silently acquire a different semantic +meaning. Product templates contain no OKW, Selenium or test-runner dependency. + ## Testing Tests use controlled `CurrentStateStore` snapshots and a fixed clock. They diff --git a/src/pylontech_console/outputs/web/routes.py b/src/pylontech_console/outputs/web/routes.py index 64c3c5e..0e0aff2 100644 --- a/src/pylontech_console/outputs/web/routes.py +++ b/src/pylontech_console/outputs/web/routes.py @@ -13,6 +13,18 @@ templates = Jinja2Templates(directory=WEB_ROOT / "templates") +def encode_test_id_component(value: str) -> str: + """Encode device identity as one reversible test-ID component.""" + allowed = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_." + return "".join( + chr(byte) if byte in allowed else f"%{byte:02X}" + for byte in value.encode("utf-8") + ) + + +templates.env.filters["testid"] = encode_test_id_component + + def create_web_router( query: StateQuery, settings: WebSettings, diff --git a/src/pylontech_console/outputs/web/templates/fragments/module_content.html b/src/pylontech_console/outputs/web/templates/fragments/module_content.html index 8739e58..d5f91f9 100644 --- a/src/pylontech_console/outputs/web/templates/fragments/module_content.html +++ b/src/pylontech_console/outputs/web/templates/fragments/module_content.html @@ -1,53 +1,54 @@ -
+
+
← Rack overview

Module detail

-

{{ page.barcode }}

+

{{ page.barcode }}

- Position {{ page.position if page.position is not none else "not installed" }} + Position {{ page.position if page.position is not none else "not installed" }} · {{ page.identity.device_name }} - · {{ "Present" if page.present else "Not present" }} + · {{ "Present" if page.present else "Not present" }}

- {% with metadata=page.detail_metadata %} + {% with metadata=page.detail_metadata, testid="module-" ~ (page.barcode | testid) ~ "-detail-status" %} {% include "fragments/status_badge.html" %} {% endwith %} - Snapshot {{ page.generated_at.strftime("%Y-%m-%d %H:%M:%S UTC") }} + Snapshot {{ page.generated_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}
-
+
Module SOC {{ page.detail.soc_percent if page.detail else "N/A" }}{% if page.detail %}%{% endif %} - {{ page.detail.basic_status if page.detail else "Unavailable" }} + {{ page.detail.basic_status if page.detail else "Unavailable" }}
-
+
Voltage {{ "%.3f"|format(page.detail.voltage_mv / 1000) if page.detail else "N/A" }} {% if page.detail %}V{% else %}Unavailable{% endif %}
-
+
Current {{ "%+.3f"|format(page.detail.current_ma / 1000) if page.detail else "N/A" }} {% if page.detail %}A{% else %}Unavailable{% endif %}
-
+
Temperature {{ "%.1f"|format(page.detail.temperature_mc / 1000) if page.detail else "N/A" }} {% if page.detail %}°C{% else %}Unavailable{% endif %}
-
+
Cell spread {{ page.cell_voltage_delta_mv if page.cell_voltage_delta_mv is not none else "N/A" }} {% if page.cell_voltage_delta_mv is not none %}mV{% else %}Unavailable{% endif %}
-
+
Cell capture {{ page.cells|length if page.cells is not none else 0 }}/{{ page.identity.cell_count }} - {% with metadata=page.cell_metadata %} + {% with metadata=page.cell_metadata, testid="module-" ~ (page.barcode | testid) ~ "-cells-status" %} {% include "fragments/status_badge.html" %} {% endwith %} @@ -55,49 +56,49 @@

{{ page.barcode }}

{% if page.detail_metadata.error or page.cell_metadata.error %} -
+
{% for error in [page.detail_metadata.error, page.cell_metadata.error] if error %} -

{{ error.group }} · {{ error.detail }}

+

{{ error.group }} · {{ error.detail }}

{% endfor %}
{% endif %}
-
+

Identity

Module information

-
Manufacturer
{{ page.identity.manufacturer }}
-
Model
{{ page.identity.device_name }}
-
Board
{{ page.identity.board_version }}
-
Main firmware
{{ page.identity.main_software_version }}
-
Software
{{ page.identity.software_version }}
-
Boot
{{ page.identity.boot_version }}
-
Release date
{{ page.identity.release_date }}
-
Specification
{{ page.identity.specification }}
+
Manufacturer
{{ page.identity.manufacturer }}
+
Model
{{ page.identity.device_name }}
+
Board
{{ page.identity.board_version }}
+
Main firmware
{{ page.identity.main_software_version }}
+
Software
{{ page.identity.software_version }}
+
Boot
{{ page.identity.boot_version }}
+
Release date
{{ page.identity.release_date }}
+
Specification
{{ page.identity.specification }}
-
+

Freshness

Acquisition status

-
Detail age
{{ "%.1f s"|format(page.detail_metadata.age_seconds) if page.detail_metadata.age_seconds is not none else "N/A" }}
-
Detail received
{{ page.detail_metadata.received_at.strftime("%H:%M:%S UTC") if page.detail_metadata.received_at else "Never" }}
-
Cell age
{{ "%.1f s"|format(page.cell_metadata.age_seconds) if page.cell_metadata.age_seconds is not none else "N/A" }}
-
Cells received
{{ page.cell_metadata.received_at.strftime("%H:%M:%S UTC") if page.cell_metadata.received_at else "Never" }}
-
Cell minimum
{{ page.minimum_cell_voltage_mv ~ " mV" if page.minimum_cell_voltage_mv is not none else "N/A" }}
-
Cell maximum
{{ page.maximum_cell_voltage_mv ~ " mV" if page.maximum_cell_voltage_mv is not none else "N/A" }}
+
Detail age
{{ "%.1f s"|format(page.detail_metadata.age_seconds) if page.detail_metadata.age_seconds is not none else "N/A" }}
+
Detail received
{{ page.detail_metadata.received_at.strftime("%H:%M:%S UTC") if page.detail_metadata.received_at else "Never" }}
+
Cell age
{{ "%.1f s"|format(page.cell_metadata.age_seconds) if page.cell_metadata.age_seconds is not none else "N/A" }}
+
Cells received
{{ page.cell_metadata.received_at.strftime("%H:%M:%S UTC") if page.cell_metadata.received_at else "Never" }}
+
Cell minimum
{{ page.minimum_cell_voltage_mv ~ " mV" if page.minimum_cell_voltage_mv is not none else "N/A" }}
+
Cell maximum
{{ page.maximum_cell_voltage_mv ~ " mV" if page.maximum_cell_voltage_mv is not none else "N/A" }}
-
+

Cell capture

All cell measurements

- {% with metadata=page.cell_metadata %} + {% with metadata=page.cell_metadata, testid="module-" ~ (page.barcode | testid) ~ "-cell-table-status" %} {% include "fragments/status_badge.html" %} {% endwith %}
@@ -121,24 +122,25 @@

All cell measurements

{% for cell in page.cells %} - + Cell {{ cell.index }} - {{ cell.voltage_mv }} mV - {{ cell.current_ma }} mA - {{ "%.1f"|format(cell.temperature_mc / 1000) }} °C - {{ cell.soc_percent }}% - {{ cell.coulomb_mah }} mAh - {{ cell.balancing }} - {{ cell.base_status }} - {{ cell.voltage_status }} - {{ cell.current_status }} - {{ cell.temperature_status }} + {{ cell.voltage_mv }} mV + {{ cell.current_ma }} mA + {{ "%.1f"|format(cell.temperature_mc / 1000) }} °C + {{ cell.soc_percent }}% + {{ cell.coulomb_mah }} mAh + {{ cell.balancing }} + {{ cell.base_status }} + {{ cell.voltage_status }} + {{ cell.current_status }} + {{ cell.temperature_status }} {% endfor %}
{% else %} -

No complete cell capture is available.

+

No complete cell capture is available.

{% endif %}
+ diff --git a/src/pylontech_console/outputs/web/templates/fragments/rack_content.html b/src/pylontech_console/outputs/web/templates/fragments/rack_content.html index be8a7f2..5fc5810 100644 --- a/src/pylontech_console/outputs/web/templates/fragments/rack_content.html +++ b/src/pylontech_console/outputs/web/templates/fragments/rack_content.html @@ -1,4 +1,5 @@ -
+
+

Live rack

Energy at cell level

@@ -8,11 +9,11 @@

Energy at cell level

- + {{ page.health.status | upper }} - + MQTT {% if page.health.mqtt.state == "connected" %} ONLINE @@ -23,73 +24,73 @@

Energy at cell level

{% endif %}
{% if page.health.mqtt.last_connected_at %} - + MQTT connected {{ page.health.mqtt.last_connected_at.strftime("%Y-%m-%d %H:%M:%S UTC") }} {% endif %} {% if page.health.mqtt.last_disconnected_at %} - + MQTT disconnected {{ page.health.mqtt.last_disconnected_at.strftime("%Y-%m-%d %H:%M:%S UTC") }} {% endif %} {% if page.health.mqtt.consecutive_failures %} - MQTT failures {{ page.health.mqtt.consecutive_failures }} + MQTT failures {{ page.health.mqtt.consecutive_failures }} {% endif %} {% if page.health.mqtt.error %} - MQTT error · {{ page.health.mqtt.error }} + MQTT error · {{ page.health.mqtt.error }} {% endif %} - + Rack data {{ "%.1f s old"|format(page.rack_metadata.age_seconds) if page.rack_metadata.age_seconds is not none else "not received" }} - Snapshot {{ page.generated_at.strftime("%Y-%m-%d %H:%M:%S UTC") }} + Snapshot {{ page.generated_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}
{% if page.errors %} -
+
{% for error in page.errors %} -

{{ error.group }} · {{ error.detail }}

+

{{ error.group }} · {{ error.detail }}

{% endfor %}
{% endif %}
-
+
Rack SOC {{ page.rack.soc_percent if page.rack else "N/A" }}{% if page.rack %}%{% endif %} State of charge
-
+
Rack voltage {{ "%.2f"|format(page.rack.voltage_mv / 1000) if page.rack else "N/A" }} {% if page.rack %}V{% else %}Unavailable{% endif %}
-
+
Rack current {{ "%+.2f"|format(page.rack.current_ma / 1000) if page.rack else "N/A" }} {% if page.rack %}A{% else %}Unavailable{% endif %}
-
+
Rack power {{ "%+.0f"|format(page.rack.derived.power_w) if page.rack else "N/A" }} {% if page.rack %}W derived{% else %}Unavailable{% endif %}
-
+
Cell spread {{ page.rack.derived.cell_voltage_delta_mv if page.rack else "N/A" }} {% if page.rack %}mV rack-wide{% else %}Unavailable{% endif %}
-
+
Modules {{ page.rack.present_modules if page.rack else page.modules|selectattr("present", "equalto", true)|list|length }} {{ page.health.cell_groups.total_cells }} cells captured
-
+

Voltage safety view

@@ -100,14 +101,14 @@

Cell-voltage heatmap

neutral zone is white. Borders show absolute voltage state.

-
+
−{{ "%.0f"|format(page.heatmap_scale_limit_mv) }} mV ±{{ "%.0f"|format(page.heatmap_deadband_mv) }} mV neutral +{{ "%.0f"|format(page.heatmap_scale_limit_mv) }} mV
-
+
Charge/balancing ≥{{ page.cell_high_balancing_mv }} mV Low ≤{{ page.cell_low_warning_mv }} · high ≥{{ page.cell_high_warning_mv }} mV @@ -133,19 +134,19 @@

Cell-voltage heatmap

{% for row in page.heatmap_rows %} - + Position {{ row.position }} - {{ row.barcode }} - {{ row.status }} + {{ row.barcode }} + {{ row.status }} - + {% if row.module_voltage_mv is not none %} {{ "%.3f"|format(row.module_voltage_mv / 1000) }} V {% else %}N/A{% endif %} - + {% if row.module_average_cell_voltage_mv is not none %} {{ "%.2f"|format(row.module_average_cell_voltage_mv) }} mV @@ -160,24 +161,26 @@

Cell-voltage heatmap

data-status="{{ cell.status }}" data-absolute-state="{{ cell.absolute_state }}" data-deviation="{{ '%.4f'|format(cell.deviation_mv) if cell.deviation_mv is not none else '' }}" + data-testid="module-{{ row.barcode | testid }}-cell-{{ cell.index }}-heatmap" > {% if cell.voltage_mv is not none %} {{ cell.voltage_mv }} - + {{ "%+.2f"|format(cell.deviation_mv) if cell.deviation_mv is not none else cell.status }} {% if cell.absolute_state not in ["normal", "current"] %} {{ cell.absolute_label }} {% endif %} {% else %} - N/A - unavailable + N/A + unavailable {% endif %} {% endfor %} @@ -187,41 +190,43 @@

Cell-voltage heatmap

{% else %} -
No present modules are available for the heatmap.
+
No present modules are available for the heatmap.
{% endif %}
-
+

Inventory

Module overview

- {% with metadata=page.health.inventory %} + {% with metadata=page.health.inventory, testid="inventory-status" %} {% include "fragments/status_badge.html" %} {% endwith %}
-
-
+

Rack detail

Limits & temperature

{% if page.rack %}
-
BMS cell average
{{ page.rack.average_cell_voltage_mv }} mV
-
Cell range
{{ page.rack.lowest_cell_voltage_mv }}–{{ page.rack.highest_cell_voltage_mv }} mV
-
Temperature range
{{ "%.1f"|format(page.rack.lowest_temperature_mc / 1000) }}–{{ "%.1f"|format(page.rack.highest_temperature_mc / 1000) }} °C
-
Average temperature
{{ "%.1f"|format(page.rack.average_temperature_mc / 1000) }} °C
-
Charge voltage
{{ "%.2f"|format(page.rack.recommended_charge_voltage_mv / 1000) }} V
-
Discharge voltage
{{ "%.2f"|format(page.rack.recommended_discharge_voltage_mv / 1000) }} V
-
Charge current
{{ "%.1f"|format(page.rack.recommended_charge_current_ma / 1000) }} A
-
Discharge current
{{ "%.1f"|format(page.rack.recommended_discharge_current_ma / 1000) }} A
-
SOH
{{ page.rack.soh_percent }}%
+
BMS cell average
{{ page.rack.average_cell_voltage_mv }} mV
+
Cell range
{{ page.rack.lowest_cell_voltage_mv }}–{{ page.rack.highest_cell_voltage_mv }} mV
+
Temperature range
{{ "%.1f"|format(page.rack.lowest_temperature_mc / 1000) }}–{{ "%.1f"|format(page.rack.highest_temperature_mc / 1000) }} °C
+
Average temperature
{{ "%.1f"|format(page.rack.average_temperature_mc / 1000) }} °C
+
Charge voltage
{{ "%.2f"|format(page.rack.recommended_charge_voltage_mv / 1000) }} V
+
Discharge voltage
{{ "%.2f"|format(page.rack.recommended_discharge_voltage_mv / 1000) }} V
+
Charge current
{{ "%.1f"|format(page.rack.recommended_charge_current_ma / 1000) }} A
+
Discharge current
{{ "%.1f"|format(page.rack.recommended_discharge_current_ma / 1000) }} A
+
SOH
{{ page.rack.soh_percent }}%
{% else %}

Rack measurements are not available.

{% endif %}
+
diff --git a/src/pylontech_console/outputs/web/templates/fragments/status_badge.html b/src/pylontech_console/outputs/web/templates/fragments/status_badge.html index d034808..0060a08 100644 --- a/src/pylontech_console/outputs/web/templates/fragments/status_badge.html +++ b/src/pylontech_console/outputs/web/templates/fragments/status_badge.html @@ -1,4 +1,7 @@ {% set current = metadata.valid and not metadata.stale %} - + {{ 'Current' if current else ('Invalid' if not metadata.valid else 'Stale') }} diff --git a/src/pylontech_console/outputs/web/templates/module.html b/src/pylontech_console/outputs/web/templates/module.html index 3cb2efa..3f007e0 100644 --- a/src/pylontech_console/outputs/web/templates/module.html +++ b/src/pylontech_console/outputs/web/templates/module.html @@ -3,6 +3,7 @@ {% block content %}
Iterator[LiveWeb]: def _assert_page(page: Page, url: str, viewport: ViewportSize) -> None: page.set_viewport_size(viewport) page.goto(url, wait_until="networkidle") - assert page.get_by_text("MQTT DISABLED", exact=True).is_visible() - assert page.get_by_role("heading", name="Cell-voltage heatmap").is_visible() + assert page.get_by_test_id("mqtt-status").is_visible() + assert page.get_by_test_id("cell-voltage-heatmap").is_visible() + assert page.get_by_test_id("module-MODULE-A-card").is_visible() + assert page.get_by_test_id( + "module-MODULE-A-cell-0-heatmap-voltage", + ).is_visible() assert page.locator(".heat-cell").count() == 30 assert page.locator(".absolute-critical").count() == 1 assert page.locator(".absolute-low-warning").count() == 2 @@ -84,8 +88,8 @@ def _assert_page(page: Page, url: str, viewport: ViewportSize) -> None: assert page.locator("body").evaluate( "(body) => body.scrollWidth <= window.innerWidth", ) - page.get_by_role("link", name="MODULE-A").first.click() - assert page.get_by_role("heading", name="MODULE-A").is_visible() + page.get_by_test_id("module-MODULE-A-heatmap-link").click() + assert page.get_by_test_id("module-MODULE-A-page").is_visible() assert page.locator(".data-table tbody tr").count() == 15 diff --git a/tests/unit/outputs/test_web.py b/tests/unit/outputs/test_web.py index 9af5b86..c5cfc7f 100644 --- a/tests/unit/outputs/test_web.py +++ b/tests/unit/outputs/test_web.py @@ -8,9 +8,14 @@ MqttHealth, ) from pylontech_console.outputs.web.query import _cell_color +from pylontech_console.outputs.web.routes import encode_test_id_component from tests.web_fixture import RECEIVED_TIME, create_web_test_app +def _test_ids(html: str) -> list[str]: + return re.findall(r'data-testid="([^"]+)"', html) + + def test_rack_overview_renders_module_heatmap_and_local_htmx() -> None: client = TestClient(create_web_test_app()) @@ -37,6 +42,53 @@ def test_rack_overview_renders_module_heatmap_and_local_htmx() -> None: assert "MQTT DISABLED" in " ".join(response.text.split()) +def test_gui_test_id_registry_is_unique_and_shared_with_fragments() -> None: + client = TestClient(create_web_test_app()) + rack_page = client.get("/").text + rack_fragment = client.get("/web/fragments/overview").text + module_page = client.get("/modules/MODULE-A").text + module_fragment = client.get( + "/web/fragments/modules/MODULE-A", + ).text + + for html in (rack_page, rack_fragment, module_page, module_fragment): + ids = _test_ids(html) + assert ids + assert len(ids) == len(set(ids)) + assert all(re.fullmatch(r"[A-Za-z0-9._%-]+", value) for value in ids) + + assert set(_test_ids(rack_page)) == set(_test_ids(rack_fragment)) | { + "rack-page", + } + assert set(_test_ids(module_page)) == set(_test_ids(module_fragment)) | { + "module-MODULE-A-page", + } + assert { + f"module-MODULE-A-cell-{index}-heatmap" + for index in range(15) + } <= set(_test_ids(rack_page)) + assert { + f"module-MODULE-A-cell-{index}-row" + for index in range(15) + } <= set(_test_ids(module_page)) + + +def test_test_id_barcode_encoding_is_reversible_and_collision_free() -> None: + assert encode_test_id_component("ABC-_.123") == "ABC-_.123" + assert encode_test_id_component("A/B% +#") == "A%2FB%25%20%2B%23" + assert encode_test_id_component("ä") == "%C3%A4" + assert encode_test_id_component("A/B") != encode_test_id_component("A%2FB") + + client = TestClient( + create_web_test_app(unsafe_barcode=''), + ) + html = client.get("/").text + + assert "module-%3Cx%2F%22%20onmouseover%3D%22secret%22%3E-card" in ( + _test_ids(html) + ) + + def test_heatmap_uses_independent_module_averages_and_fixed_deadband() -> None: client = TestClient(create_web_test_app()) @@ -183,7 +235,10 @@ def test_stale_capture_is_neutral_and_excluded_from_module_average() -> None: response = client.get("/") assert response.status_code == 200 - assert 'class="row-status row-status-stale">stale' in response.text + assert ( + 'data-testid="module-MODULE-B-heatmap-status">stale' + in response.text + ) assert response.text.count( 'class="heat-cell heat-cell-stale absolute-stale"', ) == 15