---
name: implement-widget-tests
description: "Write TDD tests for a widget type: structural tests (borders, dividers), alignment tests (icon centering), and scaling tests (proportional sizing). Uses test helpers from tests/helpers.py."
when_to_use: "When writing tests for a new widget type. Always run BEFORE implementing the renderer (TDD red phase)."
argument-hint: "[widget-type]"
arguments: widget-type
allowed-tools: Read, Edit, Write, Bash(uv *)
---

# Write Widget Tests: $widget-type

Write comprehensive tests for the **$widget-type** widget. This is the
TDD red phase — tests must FAIL until the SVG renderer is implemented.

## Before you start

1. Read the task description / spec doc for the new widget.
2. Read `tests/helpers.py` for available test helpers.
3. Read existing per-widget test files in `tests/test_render_*.py`
   for patterns. The SEPARATOR tests (`TestRenderSeparator`) in
   `tests/test_render_separator.py` are the reference for
   structural/pixel assertions. The weather tests
   (`TestRenderWeather`) in `tests/test_render_weather.py` show
   the `_DEFAULTS` + `_config()` pattern.
4. Read `custom_components/eink_dashboard/templates/_macros.svg.j2` for
   macro signatures (`card_container`, `card_row`, `chip`) so you know
   the card container insets to use in pixel region calculations.
5. Read `const.py` for `COLOR_BLACK=0`, `COLOR_WHITE=255`,
   `COLOR_GRAY=120`, `PADDING=24`.

## Existing test classes

!`grep -rn "^class Test" tests/test_render_*.py`

## Current test helper signatures

!`grep -n "^def " tests/helpers.py`

## Current WidgetMetrics sizing ratios

Call `_compute_metrics(row_h)` in tests to derive expected pixel regions
from the same ratios the renderer uses — no hardcoded magic numbers.

!`grep -n "def _compute_metrics" -A 12 custom_components/eink_dashboard/render.py`

## SVG macro signatures (from _macros.svg.j2)

!`grep -n "macro card_container\|macro card_row\|macro chip" custom_components/eink_dashboard/templates/_macros.svg.j2`

## Icon resolver (in render.py)

!`grep -n "def _device_class_icon" -A 5 custom_components/eink_dashboard/render.py`

## SVG renderer registry

!`grep -n "_SVG_RENDERERS" -A 12 custom_components/eink_dashboard/svg_render.py`

## Imports

```python
import re

from custom_components.eink_dashboard.const import (
    COLOR_BLACK, COLOR_GRAY, PADDING, DEFAULT_CARD_STYLE,
    DEFAULT_ROW_H,
)
from custom_components.eink_dashboard.render import (
    WidgetMetrics, _compute_metrics, DEFAULT_METRICS,
    _device_class_icon, render_dashboard,
)
from custom_components.eink_dashboard.svg_render import (
    render_widget_svg,
)
from custom_components.eink_dashboard.widgets._helpers import (
    _card_insets, _metrics_context, _auto_row_height,
)
from tests.helpers import (
    assert_all_white, assert_card_border, assert_has_dark_pixels,
    assert_has_gray_pixels,
    assert_scales_proportionally, assert_vertically_centered,
    content_bbox, make_config, pixel, render_to_image,
)
```

## Test structure

Create a test class `TestRender{WidgetName}` (PascalCase) in a
dedicated file `tests/test_render_{widget_type}.py`. When
redesigning an existing widget, replace the old test class in its
existing file.

### 1. Structural tests — verify visual elements exist

- **Card border/container**: dark pixels along edges for
  `card_style="border"`, gray pixels on left edge for
  `card_style="left_bar"`, all white for `"none"`
- **Default == none**: rendering without `card_style` must be
  byte-identical to `card_style="none"`. Add this test for every
  card-style widget:

  ```python
  def test_card_style_none_is_default(self) -> None:
      # Omitting card_style must produce the same output as "none".
      base = {"type": "$widget-type", "x": 0, "y": 0, "w": 400,
              "h": 56, "entities": ["sensor.temperature"]}
      with_none = render_dashboard(
          [{**base, "card_style": "none"}], self._config()
      )
      without = render_dashboard([base], self._config())
      assert with_none == without
  ```
- **Row dividers**: light-gray pixels at row boundaries (between
  entries) on 16-level displays; the divider color is
  `COLOR_LIGHT_GRAY=180`, so pass explicit bounds to the helper:
  `assert_has_gray_pixels(img, ..., low=COLOR_LIGHT_GRAY - 20,
  high=COLOR_LIGHT_GRAY + 20)`.  On 2-level displays both gray
  values dither identically, so the 2-level test path is unchanged.
- **Chip shape**: rounded corners (corner pixels white, nearby edge
  pixels dark)
- **Icon circle**: dark/gray pixels in the icon area

Use `assert_card_border(img, w, h, m)` for the four-edge border check
(default `bottom_margin=1` accommodates PIL stroke rounding).
Use `assert_has_dark_pixels()`, `assert_all_white()`,
`assert_has_gray_pixels()` for everything else.

### 2. Alignment tests — verify layout relationships

- **Icon ↔ text vertical centering**: icon center Y matches text
  center Y
- **Right-aligned values**: value text near the right edge of the
  widget

Use `assert_vertically_centered(img, icon_region, text_region,
tolerance=2.0)`. Each region is `(x1, y1, x2, y2)` absolute
coordinates.

### 3. Scaling tests — verify proportional sizing

- Render at `h=56` and `h=112`, compare `content_bbox` heights via
  `assert_scales_proportionally(..., expected_ratio=2.0,
  tolerance=0.25)`.

### 4. Data tests — verify state handling

- Missing entity: widget doesn't crash; missing state is skipped
  silently
- Missing attributes: handles absent `device_class`, `friendly_name`
- Edge cases: empty entity list, single entity

### 5. Locale formatting test — numeric-state widgets only

If the widget displays numeric entity states (via `_fmt()`), add a
test to the existing `TestLocaleFormattingInWidgets` class in
`tests/test_render_cross_widget.py`.  Do NOT create a separate
class.

```python
def test_{widget_type}_decimal_comma(self) -> None:
    # {WidgetName} secondary text must use comma decimal for
    # German locale.
    widget = {
        "type": "$widget-type",
        "entity": "sensor.humidity",
    }
    config = self._config(
        states={
            "sensor.humidity": {
                "state": "8.41",
                "attributes": {"unit_of_measurement": "g/m³"},
            }
        },
        number_format="decimal_comma",
        language="de",
    )
    svg = render_widget_svg(widget, config)
    assert "8,41" in svg
    assert "8.41" not in svg
```

Widgets that display only non-numeric states (on/off, binary sensors,
labels) do not need this test.

### 7. Auto-sizing tests — row-based widgets only

For row-based widgets (height derived from content rows), verify the
auto-sizing fallback.  Use `render_widget_svg` directly — a
full-canvas PNG from `render_dashboard` cannot reveal the widget's
own height attribute.

- **No `h`, one row**: `SVG height == DEFAULT_ROW_H`
- **No `h`, N rows**: `SVG height == N * DEFAULT_ROW_H`
- **Explicit `h` preserved**: widget with explicit `h` produces that
  exact height regardless of row count

```python
def test_{widget}_auto_height_single_row(self) -> None:
    # Without explicit h, one row produces height DEFAULT_ROW_H.
    w = {
        "type": "$widget-type",
        "x": 0, "y": 0, "w": 400,
        "entities": ["sensor.temperature"],
    }
    svg = render_widget_svg(w, self._config())
    m = re.search(r'height="(\d+)"', svg)
    assert m is not None
    assert int(m.group(1)) == DEFAULT_ROW_H

def test_{widget}_explicit_h_preserved(self) -> None:
    # An explicit h overrides auto-sizing.
    w = {
        "type": "$widget-type",
        "x": 0, "y": 0, "w": 400, "h": 200,
        "entities": ["sensor.temperature"],
    }
    svg = render_widget_svg(w, self._config())
    m = re.search(r'height="(\d+)"', svg)
    assert m is not None
    assert int(m.group(1)) == 200
```

## Mock state setup

Define mock states at **module level** (not pytest fixtures —
conftest.py only has HA stubs). Include `device_class` in attributes
(required for icon resolution in redesigned widgets). For binary
sensors, define separate entries for `on` and `off` states to test
state-dependent icon resolution:

```python
MOCK_{WIDGET}_STATES = {
    "sensor.temperature": {
        "state": "22.5",
        "attributes": {
            "friendly_name": "Living Room",
            "device_class": "temperature",
            "unit_of_measurement": "°C",
        },
    },
    "binary_sensor.front_door": {
        "state": "off",
        "attributes": {
            "friendly_name": "Front Door",
            "device_class": "door",
        },
    },
}
```

**Lesson from completed cycles:** The old `MOCK_SENSOR_STATES` lacked
`device_class`, which caused icon resolution to fall back to letter
labels. Always include `device_class` in mock attributes.

## Widget config shape (redesigned widgets)

Redesigned widgets use `w` and `h` instead of `font_size`. The `w`
parameter defines the card/chip boundary width, and `h` defines the
total widget height. All internal dimensions derive from `h`.

```python
# Card-style widget (tile, waste_schedule, person, alarm)
# "h" is optional: omit it for auto-sizing (height =
# num_rows * DEFAULT_ROW_H).  Include it to test explicit override.
widget = {
    "type": "$widget-type",
    "x": PADDING, "y": 0, "w": 350, "h": 112,
    "entities": ["sensor.temperature", "sensor.humidity"],
    "card_style": "border",  # or "left_bar" or "none"
}

# Chip-style widget (lock)
widget = {
    "type": "$widget-type",
    "x": PADDING, "y": 0, "w": 350, "h": 28,
    "entities": ["binary_sensor.front_door"],
}
```

**Do NOT use `font_size`** in redesigned widget configs. The TEXT
widget is the only widget that keeps `font_size`.

## Test class pattern

```python
class TestRender{WidgetName}:
    # Verify rendering of $widget-type widgets.
    _DEFAULTS: dict[str, object] = {
        "width": 400,
        "height": 300,
        "states": MOCK_{WIDGET}_STATES,
    }

    def _config(self, **overrides: object) -> dict[str, object]:
        return make_config(self._DEFAULTS, **overrides)

    def test_{widget}_draws_content(self) -> None:
        # Verify that content is rendered in the expected region.
        widgets = [{
            "type": "$widget-type",
            "x": PADDING, "y": 0, "w": 350, "h": 56,
            "entities": ["sensor.temperature"],
        }]
        img = render_to_image(widgets, self._config())
        assert_has_dark_pixels(img, PADDING, 0, 350, 56)

    def test_{widget}_empty_entities_white(self) -> None:
        # Empty entity list produces blank output.
        widgets = [{
            "type": "$widget-type",
            "x": PADDING, "y": 0, "w": 350, "h": 56,
            "entities": [],
        }]
        img = render_to_image(widgets, self._config())
        assert_all_white(img, PADDING, 0, 350, 56)

    def test_{widget}_icon_centered_with_text(self) -> None:
        # Verify icon circle is vertically centered with text block.
        m = _compute_metrics(56)
        widgets = [{
            "type": "$widget-type",
            "x": 0, "y": 0, "w": 400, "h": 56,
            "entities": ["sensor.temperature"],
        }]
        img = render_to_image(widgets, self._config())
        assert_vertically_centered(
            img,
            icon_region=(m.padding, 0, m.padding + m.icon_dia, 56),
            text_region=(
                m.padding + m.icon_dia + m.inner_gap, 0, 380, 56
            ),
        )
```

## Computing expected pixel regions

Derive regions from `_compute_metrics(row_h)` so tests stay in sync
with the renderer's own layout:

```python
m = _compute_metrics(56)  # row_h = widget h / number of rows
# Card border:
#   x in [0, m.border] and [w - m.border, w]
# Icon circle:
#   x in [x_off + m.padding, x_off + m.padding + m.icon_dia]
# Icon glyph size (inside the circle):
#   m.icon_inner  (do NOT compute as m.icon_dia * 6 // 10)
# Text start:
#   x = x_off + m.padding + m.icon_dia + m.inner_gap
# Row divider:
#   y = row_y + row_h  (height = m.divider)
# card_container macro passes (x_off, right_inset) to caller:
#   "border"   -> (m.padding, m.padding)
#   "left_bar" -> (bar_w + m.padding, 0)
#   "none"     -> (0, 0)
# Content width (cw):
#   cw = w - x_off - right_inset
# _card_insets(m, card_style, display_levels) returns
# (x_off, r_inset, bar_width). Derive lpad/rpad from the result:
#   x_off, r_inset, _ = _card_insets(m, card_style, display_levels)
#   lpad = m.padding if x_off == 0 else 0
#   rpad = m.padding if r_inset == 0 else 0
# Icon circle left arc lands at x_off + lpad, NOT x_off + m.padding.
# Divider lines span x_off + lpad .. w - r_inset - rpad.
```

## Lessons from completed TDD cycles

1. **Pixel-level assertions for borders**: The SEPARATOR tests use
   exact `pixel()` checks (e.g. `pixel(img, x, 50) == COLOR_BLACK`)
   for precise structural verification. Use these when the exact
   position is known.

2. **Region-based assertions for content**: Use
   `assert_has_dark_pixels()` for areas where content location is
   approximate (text rendering varies by font backend).

3. **`content_bbox()` for measuring**: Use it to find actual rendered
   content size for scaling and alignment tests. It returns the tight
   bounding box of non-white pixels.

4. **2-level display tests**: When the widget uses gray elements
   (dividers, bars, left_bar), test the `display_levels=2` path
   that widens them. Pass it via config:
   `config = {**self._CONFIG, "display_levels": 2}`.

5. **Test each `card_style` variant**: For card-style widgets, test
   all three styles (`"border"`, `"left_bar"`, `"none"`) in separate
   test methods. The SEPARATOR tests show this pattern well.

6. **2-level icon ring bounds**: When a helper computes the ring
   check area inside an icon circle (the region above the glyph),
   it must account for the wider stroke on 2-level displays.
   Mirror the context builder's `icon_stroke_w` formula so the
   check region stays inside the stroke inner edge:

   ```python
   icon_stroke_w = (
       m.border * 3 if display_levels <= 2 else m.border
   )
   ring_y1 = icon_cy - icon_r + icon_stroke_w // 2 + 3
   ring_y2 = icon_cy - m.icon_inner // 2 - 1
   ```

   Without this adjustment, the wider stroke's antialiased inner
   edge bleeds into the ring region, producing false gray-pixel
   hits in the 2-level test path.

6. **Missing entity = no crash**: Always include a test that passes a
   nonexistent entity ID and verifies the widget doesn't crash.
   Also test empty entity list → all white canvas.

7. **Each test needs a comment**: Every test function must start with
   a short comment explaining what it verifies.

8. **Engine-agnostic tests only**: Tests call `render_dashboard()` and
   inspect PNG output. Do NOT import or call internal drawing helpers
   from tests. The entry point (`render_dashboard`) dispatches
   transparently to the SVG pipeline in `svg_render.py`.
   **Exception**: auto-sizing tests (§5) call `render_widget_svg`
   directly to read the SVG `height` attribute — it cannot be inferred
   from a full-canvas PNG.

## Font metric tolerance

The SVG pipeline uses resvg for text rendering, which may produce
slightly different glyph metrics than PIL. Keep the following in mind:

- Use region-based assertions (`assert_has_dark_pixels`,
  `assert_has_gray_pixels`) rather than exact pixel checks for text
  content. Text position may shift by 1-3 pixels between engines.
- Use `tolerance=3.0` (instead of 2.0) for
  `assert_vertically_centered` when testing icon-text alignment.
  resvg's `dominant-baseline="central"` differs slightly from PIL's
  ascender-based centering.
- `assert_scales_proportionally` tolerance of 0.25 remains sufficient
  — proportional scaling is engine-agnostic.
- Exact `pixel()` checks are fine for geometry (borders, dividers,
  chip corners) — these are SVG shapes, not text.

## Verification

Run tests — they should FAIL at this point (TDD red phase):

```bash
uv run --group test pytest \
    tests/test_render_{widget_type}.py::TestRender{WidgetName} -v
```

The tests define the expected behavior. The renderer implementation
(via `/implement-widget`) makes them pass.

## Key references

- Widget behavior source: task description / spec doc for the new widget
- Test helpers: `tests/helpers.py`
- Reference structural tests: `TestRenderSeparator` in
  `tests/test_render_separator.py`
- Reference `_DEFAULTS` + `_config()` pattern: `TestRenderWeather`
  in `tests/test_render_weather.py`
- Sizing ratios: `_compute_metrics()` in `render.py`
- SVG macros: `templates/_macros.svg.j2`
- Colors: `COLOR_BLACK=0`, `COLOR_WHITE=255`, `COLOR_GRAY=120` in
  `const.py`
