Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions docs/contracts/web-ui-v0.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<zero-based-error-index>
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-<position>
cell-voltage-heatmap
cell-voltage-heatmap-unavailable
cell-voltage-heatmap-legend
cell-voltage-absolute-legend
```

Per-module rack IDs, where `<module>` is the encoded barcode, are:

```text
module-<module>-card
module-<module>-barcode
module-<module>-position
module-<module>-soc
module-<module>-state
module-<module>-voltage-summary
module-<module>-cell-voltage-delta
module-<module>-detail-status
module-<module>-cells-status
module-<module>-heatmap-row
module-<module>-heatmap-link
module-<module>-heatmap-status
module-<module>-voltage
module-<module>-cell-average
module-<module>-cell-<index>-heatmap
module-<module>-cell-<index>-heatmap-voltage
module-<module>-cell-<index>-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 `<module>`:

```text
module-<module>-page
module-<module>-current-state
module-<module>-header
module-<module>-barcode
module-<module>-position
module-<module>-present
module-<module>-snapshot-at
module-<module>-soc
module-<module>-state
module-<module>-voltage
module-<module>-current
module-<module>-temperature
module-<module>-cell-voltage-delta
module-<module>-cell-capture
module-<module>-detail-status
module-<module>-cells-status
module-<module>-errors
module-<module>-error-<zero-based-error-index>
module-<module>-identity
module-<module>-identity-<modeled-field>
module-<module>-freshness
module-<module>-freshness-<field>
module-<module>-cells
module-<module>-cell-table-status
module-<module>-cells-unavailable
module-<module>-cell-<index>-row
module-<module>-cell-<index>-voltage
module-<module>-cell-<index>-current
module-<module>-cell-<index>-temperature
module-<module>-cell-<index>-soc
module-<module>-cell-<index>-coulomb
module-<module>-cell-<index>-balancing
module-<module>-cell-<index>-base-status
module-<module>-cell-<index>-voltage-status
module-<module>-cell-<index>-current-status
module-<module>-cell-<index>-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
Expand Down
12 changes: 12 additions & 0 deletions src/pylontech_console/outputs/web/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,103 +1,104 @@
<section class="page-heading">
<div data-testid="module-{{ page.barcode | testid }}-current-state">
<section class="page-heading" data-testid="module-{{ page.barcode | testid }}-header">
<div>
<a class="back-link" href="/">← Rack overview</a>
<p class="eyebrow">Module detail</p>
<h1>{{ page.barcode }}</h1>
<h1 data-testid="module-{{ page.barcode | testid }}-barcode">{{ page.barcode }}</h1>
<p>
Position {{ page.position if page.position is not none else "not installed" }}
<span data-testid="module-{{ page.barcode | testid }}-position">Position {{ page.position if page.position is not none else "not installed" }}</span>
· {{ page.identity.device_name }}
· {{ "Present" if page.present else "Not present" }}
· <span data-testid="module-{{ page.barcode | testid }}-present">{{ "Present" if page.present else "Not present" }}</span>
</p>
</div>
<div class="hero-status">
{% with metadata=page.detail_metadata %}
{% with metadata=page.detail_metadata, testid="module-" ~ (page.barcode | testid) ~ "-detail-status" %}
{% include "fragments/status_badge.html" %}
{% endwith %}
<small>Snapshot {{ page.generated_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</small>
<small data-testid="module-{{ page.barcode | testid }}-snapshot-at">Snapshot {{ page.generated_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</small>
</div>
</section>

<section class="metrics" aria-label="Module measurements">
<article class="metric metric-primary">
<article class="metric metric-primary" data-testid="module-{{ page.barcode | testid }}-soc">
<span>Module SOC</span>
<strong>{{ page.detail.soc_percent if page.detail else "N/A" }}{% if page.detail %}%{% endif %}</strong>
<small>{{ page.detail.basic_status if page.detail else "Unavailable" }}</small>
<small data-testid="module-{{ page.barcode | testid }}-state">{{ page.detail.basic_status if page.detail else "Unavailable" }}</small>
</article>
<article class="metric">
<article class="metric" data-testid="module-{{ page.barcode | testid }}-voltage">
<span>Voltage</span>
<strong>{{ "%.3f"|format(page.detail.voltage_mv / 1000) if page.detail else "N/A" }}</strong>
<small>{% if page.detail %}V{% else %}Unavailable{% endif %}</small>
</article>
<article class="metric">
<article class="metric" data-testid="module-{{ page.barcode | testid }}-current">
<span>Current</span>
<strong>{{ "%+.3f"|format(page.detail.current_ma / 1000) if page.detail else "N/A" }}</strong>
<small>{% if page.detail %}A{% else %}Unavailable{% endif %}</small>
</article>
<article class="metric">
<article class="metric" data-testid="module-{{ page.barcode | testid }}-temperature">
<span>Temperature</span>
<strong>{{ "%.1f"|format(page.detail.temperature_mc / 1000) if page.detail else "N/A" }}</strong>
<small>{% if page.detail %}°C{% else %}Unavailable{% endif %}</small>
</article>
<article class="metric">
<article class="metric" data-testid="module-{{ page.barcode | testid }}-cell-voltage-delta">
<span>Cell spread</span>
<strong>{{ page.cell_voltage_delta_mv if page.cell_voltage_delta_mv is not none else "N/A" }}</strong>
<small>{% if page.cell_voltage_delta_mv is not none %}mV{% else %}Unavailable{% endif %}</small>
</article>
<article class="metric">
<article class="metric" data-testid="module-{{ page.barcode | testid }}-cell-capture">
<span>Cell capture</span>
<strong>{{ page.cells|length if page.cells is not none else 0 }}/{{ page.identity.cell_count }}</strong>
<small>
{% with metadata=page.cell_metadata %}
{% with metadata=page.cell_metadata, testid="module-" ~ (page.barcode | testid) ~ "-cells-status" %}
{% include "fragments/status_badge.html" %}
{% endwith %}
</small>
</article>
</section>

{% if page.detail_metadata.error or page.cell_metadata.error %}
<section class="alerts" aria-label="Module acquisition errors">
<section class="alerts" aria-label="Module acquisition errors" data-testid="module-{{ page.barcode | testid }}-errors">
{% for error in [page.detail_metadata.error, page.cell_metadata.error] if error %}
<p><strong>{{ error.group }}</strong> · {{ error.detail }}</p>
<p data-testid="module-{{ page.barcode | testid }}-error-{{ loop.index0 }}"><strong>{{ error.group }}</strong> · {{ error.detail }}</p>
{% endfor %}
</section>
{% endif %}

<section class="two-column">
<article class="panel">
<article class="panel" data-testid="module-{{ page.barcode | testid }}-identity">
<p class="eyebrow">Identity</p>
<h2>Module information</h2>
<dl class="detail-grid">
<div><dt>Manufacturer</dt><dd>{{ page.identity.manufacturer }}</dd></div>
<div><dt>Model</dt><dd>{{ page.identity.device_name }}</dd></div>
<div><dt>Board</dt><dd>{{ page.identity.board_version }}</dd></div>
<div><dt>Main firmware</dt><dd>{{ page.identity.main_software_version }}</dd></div>
<div><dt>Software</dt><dd>{{ page.identity.software_version }}</dd></div>
<div><dt>Boot</dt><dd>{{ page.identity.boot_version }}</dd></div>
<div><dt>Release date</dt><dd>{{ page.identity.release_date }}</dd></div>
<div><dt>Specification</dt><dd>{{ page.identity.specification }}</dd></div>
<div data-testid="module-{{ page.barcode | testid }}-identity-manufacturer"><dt>Manufacturer</dt><dd>{{ page.identity.manufacturer }}</dd></div>
<div data-testid="module-{{ page.barcode | testid }}-identity-model"><dt>Model</dt><dd>{{ page.identity.device_name }}</dd></div>
<div data-testid="module-{{ page.barcode | testid }}-identity-board"><dt>Board</dt><dd>{{ page.identity.board_version }}</dd></div>
<div data-testid="module-{{ page.barcode | testid }}-identity-main-firmware"><dt>Main firmware</dt><dd>{{ page.identity.main_software_version }}</dd></div>
<div data-testid="module-{{ page.barcode | testid }}-identity-software"><dt>Software</dt><dd>{{ page.identity.software_version }}</dd></div>
<div data-testid="module-{{ page.barcode | testid }}-identity-boot"><dt>Boot</dt><dd>{{ page.identity.boot_version }}</dd></div>
<div data-testid="module-{{ page.barcode | testid }}-identity-release-date"><dt>Release date</dt><dd>{{ page.identity.release_date }}</dd></div>
<div data-testid="module-{{ page.barcode | testid }}-identity-specification"><dt>Specification</dt><dd>{{ page.identity.specification }}</dd></div>
</dl>
</article>
<article class="panel">
<article class="panel" data-testid="module-{{ page.barcode | testid }}-freshness">
<p class="eyebrow">Freshness</p>
<h2>Acquisition status</h2>
<dl class="detail-grid">
<div><dt>Detail age</dt><dd>{{ "%.1f s"|format(page.detail_metadata.age_seconds) if page.detail_metadata.age_seconds is not none else "N/A" }}</dd></div>
<div><dt>Detail received</dt><dd>{{ page.detail_metadata.received_at.strftime("%H:%M:%S UTC") if page.detail_metadata.received_at else "Never" }}</dd></div>
<div><dt>Cell age</dt><dd>{{ "%.1f s"|format(page.cell_metadata.age_seconds) if page.cell_metadata.age_seconds is not none else "N/A" }}</dd></div>
<div><dt>Cells received</dt><dd>{{ page.cell_metadata.received_at.strftime("%H:%M:%S UTC") if page.cell_metadata.received_at else "Never" }}</dd></div>
<div><dt>Cell minimum</dt><dd>{{ page.minimum_cell_voltage_mv ~ " mV" if page.minimum_cell_voltage_mv is not none else "N/A" }}</dd></div>
<div><dt>Cell maximum</dt><dd>{{ page.maximum_cell_voltage_mv ~ " mV" if page.maximum_cell_voltage_mv is not none else "N/A" }}</dd></div>
<div data-testid="module-{{ page.barcode | testid }}-freshness-detail-age"><dt>Detail age</dt><dd>{{ "%.1f s"|format(page.detail_metadata.age_seconds) if page.detail_metadata.age_seconds is not none else "N/A" }}</dd></div>
<div data-testid="module-{{ page.barcode | testid }}-freshness-detail-received-at"><dt>Detail received</dt><dd>{{ page.detail_metadata.received_at.strftime("%H:%M:%S UTC") if page.detail_metadata.received_at else "Never" }}</dd></div>
<div data-testid="module-{{ page.barcode | testid }}-freshness-cell-age"><dt>Cell age</dt><dd>{{ "%.1f s"|format(page.cell_metadata.age_seconds) if page.cell_metadata.age_seconds is not none else "N/A" }}</dd></div>
<div data-testid="module-{{ page.barcode | testid }}-freshness-cells-received-at"><dt>Cells received</dt><dd>{{ page.cell_metadata.received_at.strftime("%H:%M:%S UTC") if page.cell_metadata.received_at else "Never" }}</dd></div>
<div data-testid="module-{{ page.barcode | testid }}-freshness-cell-minimum-voltage"><dt>Cell minimum</dt><dd>{{ page.minimum_cell_voltage_mv ~ " mV" if page.minimum_cell_voltage_mv is not none else "N/A" }}</dd></div>
<div data-testid="module-{{ page.barcode | testid }}-freshness-cell-maximum-voltage"><dt>Cell maximum</dt><dd>{{ page.maximum_cell_voltage_mv ~ " mV" if page.maximum_cell_voltage_mv is not none else "N/A" }}</dd></div>
</dl>
</article>
</section>

<section class="panel">
<section class="panel" data-testid="module-{{ page.barcode | testid }}-cells">
<div class="section-heading">
<div>
<p class="eyebrow">Cell capture</p>
<h2>All cell measurements</h2>
</div>
{% with metadata=page.cell_metadata %}
{% with metadata=page.cell_metadata, testid="module-" ~ (page.barcode | testid) ~ "-cell-table-status" %}
{% include "fragments/status_badge.html" %}
{% endwith %}
</div>
Expand All @@ -121,24 +122,25 @@ <h2>All cell measurements</h2>
</thead>
<tbody>
{% for cell in page.cells %}
<tr>
<tr data-testid="module-{{ page.barcode | testid }}-cell-{{ cell.index }}-row">
<th scope="row">Cell {{ cell.index }}</th>
<td>{{ cell.voltage_mv }} mV</td>
<td>{{ cell.current_ma }} mA</td>
<td>{{ "%.1f"|format(cell.temperature_mc / 1000) }} °C</td>
<td>{{ cell.soc_percent }}%</td>
<td>{{ cell.coulomb_mah }} mAh</td>
<td>{{ cell.balancing }}</td>
<td>{{ cell.base_status }}</td>
<td>{{ cell.voltage_status }}</td>
<td>{{ cell.current_status }}</td>
<td>{{ cell.temperature_status }}</td>
<td data-testid="module-{{ page.barcode | testid }}-cell-{{ cell.index }}-voltage">{{ cell.voltage_mv }} mV</td>
<td data-testid="module-{{ page.barcode | testid }}-cell-{{ cell.index }}-current">{{ cell.current_ma }} mA</td>
<td data-testid="module-{{ page.barcode | testid }}-cell-{{ cell.index }}-temperature">{{ "%.1f"|format(cell.temperature_mc / 1000) }} °C</td>
<td data-testid="module-{{ page.barcode | testid }}-cell-{{ cell.index }}-soc">{{ cell.soc_percent }}%</td>
<td data-testid="module-{{ page.barcode | testid }}-cell-{{ cell.index }}-coulomb">{{ cell.coulomb_mah }} mAh</td>
<td data-testid="module-{{ page.barcode | testid }}-cell-{{ cell.index }}-balancing">{{ cell.balancing }}</td>
<td data-testid="module-{{ page.barcode | testid }}-cell-{{ cell.index }}-base-status">{{ cell.base_status }}</td>
<td data-testid="module-{{ page.barcode | testid }}-cell-{{ cell.index }}-voltage-status">{{ cell.voltage_status }}</td>
<td data-testid="module-{{ page.barcode | testid }}-cell-{{ cell.index }}-current-status">{{ cell.current_status }}</td>
<td data-testid="module-{{ page.barcode | testid }}-cell-{{ cell.index }}-temperature-status">{{ cell.temperature_status }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No complete cell capture is available.</p>
<p class="empty-state" data-testid="module-{{ page.barcode | testid }}-cells-unavailable">No complete cell capture is available.</p>
{% endif %}
</section>
</div>
Loading