diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index ed448eb4..93276b7d 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -30,6 +30,15 @@ module.exports = {
'import/no-extraneous-dependencies': 'off', // Test deps (vitest, etc.) are devDependencies
'sort-keys': 'off' // Test objects are ordered for readability, not alphabetically
}
+ },
+ {
+ files: ['pbt/**/*.ts'],
+ parserOptions: {
+ project: ['./pbt/tsconfig.json']
+ },
+ rules: {
+ 'import/no-extraneous-dependencies': 'off' // bombadil is a devDependency
+ }
}
],
parser: '@typescript-eslint/parser',
diff --git a/.github/workflows/bombadil.yml b/.github/workflows/bombadil.yml
new file mode 100644
index 00000000..b8cc9f0e
--- /dev/null
+++ b/.github/workflows/bombadil.yml
@@ -0,0 +1,37 @@
+---
+name: Bombadil property tests
+
+# GHA requires bare `on:` which yamllint sees as boolean
+# yamllint disable-line rule:truthy
+on:
+ schedule:
+ - cron: "17 8 * * *"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ bombadil:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v7
+ - name: Set up Node
+ uses: actions/setup-node@v7
+ with:
+ node-version: lts/jod
+ cache: yarn
+ - name: Install dependencies
+ run: yarn install --frozen-lockfile
+ - name: Run Bombadil exploration
+ env:
+ BOMBADIL_HEADLESS: "1"
+ BOMBADIL_TIME_LIMIT: 5m
+ run: yarn test:pbt
+ - name: Upload trace on violation
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: bombadil-output
+ path: pbt-output/
diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml
index 0166c64e..b301300b 100644
--- a/.github/workflows/python-checks.yml
+++ b/.github/workflows/python-checks.yml
@@ -91,6 +91,8 @@ jobs:
run: uv pip install --system -r requirements_dev.txt pytest-cov pytest-github-actions-annotate-failures
- name: Run tests and generate coverage report
run: pytest ./tests/ --cov=custom_components/lock_code_manager/ --cov-report=xml --junitxml=junit.xml
+ env:
+ HYPOTHESIS_PROFILE: ci
- name: Upload coverage to Codecov
if: matrix.python-version == needs.setup.outputs.target-python
uses: codecov/codecov-action@v7
diff --git a/.gitignore b/.gitignore
index 68a8a915..568fc1df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ venv
.codex
.coverage
coverage/
+.hypothesis/
.pytest_cache
.mypy_cache
.ruff_cache
@@ -22,3 +23,4 @@ docs/
.github/hooks/
.opencode/
junit.xml
+pbt-output/
diff --git a/AGENTS.md b/AGENTS.md
index 254f7ce6..9ed9b407 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -316,6 +316,29 @@ tests/providers/
- Provider `async_setup` must be idempotent — tests verify it can be called multiple times.
- Don't use `MagicMock()` for coordinators when the real coordinator can be used.
+## Property-based testing
+
+Two complementary layers exist alongside the example-based suites:
+
+- **Python (Hypothesis)** — `tests/properties/` runs as part of the normal
+ `pytest tests/` invocation. The default `dev` profile uses 15 examples to
+ keep the suite fast; CI sets `HYPOTHESIS_PROFILE=ci` (200 examples). Run
+ the deep profile locally with `HYPOTHESIS_PROFILE=ci pytest tests/properties/`.
+ `test_credential_machine.py` is a stateful machine driving the real
+ BaseLock write orchestration against `MockLCMLock`.
+- **TypeScript (Bombadil)** — `pbt/` contains a browser harness that mounts
+ the built cards with a scripted mock `hass` plus a chaos panel, and a
+ Bombadil spec (`pbt/spec.ts`) with temporal-logic properties (no PIN leaks
+ from masked cards, chip counts match the model, pushed data eventually
+ renders). Run locally with `yarn test:pbt` (env knobs:
+ `BOMBADIL_TIME_LIMIT`, `BOMBADIL_HEADLESS=1`). CI runs it nightly and on
+ manual dispatch (`bombadil.yml`), never on pull requests. Inspect
+ violations with `yarn bombadil browser inspect pbt-output`.
+
+Counterexample triage: a found counterexample is a deliverable. Either fix
+the code, or — only for documented contract edges — narrow the strategy
+with a comment citing the contract. Never silence one by rerunning.
+
## Adding Lock Provider Support
1. Create new file in `providers/` (e.g., `my_provider.py`)
diff --git a/custom_components/lock_code_manager/providers/_util.py b/custom_components/lock_code_manager/providers/_util.py
index e63e0167..c97f2d29 100644
--- a/custom_components/lock_code_manager/providers/_util.py
+++ b/custom_components/lock_code_manager/providers/_util.py
@@ -98,7 +98,8 @@ def parse_slot_num(value: object) -> int | None:
Convert a slot identifier to an int, or return None if not convertible.
Mirrors ``int(value)`` while collapsing the ``TypeError``/``ValueError``
- that providers otherwise catch when a lock reports a non-numeric slot key.
+ that providers otherwise catch when a lock reports a non-numeric slot key
+ (and ``OverflowError``, which ``int`` raises for infinite floats).
JSON booleans are rejected rather than coerced (``int(True)`` is 1, so a
malformed ``true`` would otherwise silently address slot 1).
Call sites remain responsible for their own logging and skip/return flow.
@@ -107,5 +108,5 @@ def parse_slot_num(value: object) -> int | None:
return None
try:
return int(value) # type: ignore[call-overload]
- except TypeError, ValueError:
+ except TypeError, ValueError, OverflowError:
return None
diff --git a/package.json b/package.json
index 142b3737..c5c24a35 100644
--- a/package.json
+++ b/package.json
@@ -14,11 +14,12 @@
"scripts": {
"build": "yarn install ; rollup -c",
"watch": "rollup -c --watch",
- "lint": "eslint --ext .ts ./ts",
+ "lint": "eslint --ext .ts ./ts ./pbt",
"lint:fix": "yarn lint --fix",
"test": "vitest run",
"test:watch": "vitest",
- "test:coverage": "vitest run --coverage"
+ "test:coverage": "vitest run --coverage",
+ "test:pbt": "yarn build && node pbt/run.mjs"
},
"dependencies": {
"@mdi/js": "^7.4.47",
@@ -27,6 +28,7 @@
"lit-html": "^3.3.3"
},
"devDependencies": {
+ "@antithesishq/bombadil": "0.6.1",
"@babel/core": "^7.29.7",
"@babel/preset-env": "^7.29.7",
"@rollup/plugin-babel": "^7.1.0",
diff --git a/pbt/harness/harness.js b/pbt/harness/harness.js
new file mode 100644
index 00000000..2f35f67d
--- /dev/null
+++ b/pbt/harness/harness.js
@@ -0,0 +1,222 @@
+/**
+ * Bombadil harness: mounts lcm-slot and lcm-lock-codes with a scripted mock
+ * hass. Plain JavaScript on purpose — no build step; served statically next
+ * to the built bundle. The model below is the single source of truth; chaos
+ * buttons mutate it and re-push through the subscription callbacks, the same
+ * path the real websocket API uses.
+ */
+
+const CONFIG_ENTRY_ID = 'pbt_entry';
+const LOCK_ENTITY_ID = 'lock.pbt_front_door';
+
+const model = {
+ revision: 0,
+ suspended: false,
+ slots: {
+ 1: { active: true, enabled: true, inSync: true, name: 'Alice', pin: '4921' },
+ 2: { active: false, enabled: false, inSync: true, name: 'Bob', pin: '8375' },
+ 3: { active: true, enabled: true, inSync: false, name: 'Carol', pin: '6104' }
+ }
+};
+
+// Read-only window surface for spec extractors. Secret PINs deliberately
+// live here (JavaScript only), never in the DOM: the no-leak property greps
+// the rendered DOM for them.
+window.__lcmHarness = {
+ model,
+ secretPins: () => Object.values(model.slots).map((slot) => slot.pin)
+};
+
+/** Subscriptions keyed by an opaque id -> {message, callback}. */
+const subscriptions = new Map();
+let nextSubscriptionId = 1;
+
+function slotCardData(slotNum) {
+ const slot = model.slots[slotNum];
+ return {
+ active: slot.active,
+ conditions: {},
+ config_entry_id: CONFIG_ENTRY_ID,
+ config_entry_title: 'PBT Lock Manager',
+ enabled: slot.enabled,
+ entities: {
+ active: `binary_sensor.slot_${slotNum}_active`,
+ enabled: `switch.slot_${slotNum}_enabled`,
+ name: `text.slot_${slotNum}_name`,
+ pin: `text.slot_${slotNum}_pin`
+ },
+ locks: [
+ {
+ code: null,
+ code_length: slot.pin.length,
+ entity_id: LOCK_ENTITY_ID,
+ in_sync: slot.inSync,
+ name: 'Front Door',
+ sync_status: slot.inSync ? 'in_sync' : 'out_of_sync'
+ }
+ ],
+ name: slot.name,
+ // Harness cards run code_display: 'masked' — the PIN value never
+ // leaves the model; only its length does.
+ pin: null,
+ pin_length: slot.pin.length,
+ slot_num: slotNum
+ };
+}
+
+function lockCoordinatorData() {
+ return {
+ lock_entity_id: LOCK_ENTITY_ID,
+ lock_name: 'Front Door',
+ ...(model.suspended ? { sync_status: 'suspended' } : {}),
+ slots: Object.entries(model.slots).map(([slotNum, slot]) => ({
+ active: slot.active,
+ code: null,
+ code_length: slot.pin.length,
+ config_entry_id: CONFIG_ENTRY_ID,
+ config_entry_title: 'PBT Lock Manager',
+ in_sync: slot.inSync,
+ // ts/types.ts LockCoordinatorSlotData names this `managed`
+ // (lock-codes-card.ts reads `slot.managed` throughout); the
+ // original draft used `is_managed`, which the card would have
+ // silently ignored.
+ managed: true,
+ name: slot.name,
+ slot: Number(slotNum)
+ }))
+ };
+}
+
+function payloadFor(message) {
+ if (message.type === 'lock_code_manager/subscribe_code_slot') {
+ return model.slots[message.slot] ? slotCardData(message.slot) : null;
+ }
+ if (message.type === 'lock_code_manager/subscribe_lock_codes') {
+ return lockCoordinatorData();
+ }
+ return null;
+}
+
+function pushAll() {
+ model.revision += 1;
+ for (const { callback, message } of subscriptions.values()) {
+ const payload = payloadFor(message);
+ if (payload !== null) {
+ callback(payload);
+ }
+ }
+}
+
+const mockHass = {
+ callService: (domain, service, data) => {
+ // Card-initiated edits loop back through the model like the real
+ // backend would.
+ pushAll();
+ return Promise.resolve({ domain, data, service });
+ },
+ callWS: () => Promise.resolve({}),
+ connection: {
+ subscribeMessage: (callback, message) => {
+ const id = nextSubscriptionId;
+ nextSubscriptionId += 1;
+ subscriptions.set(id, { callback, message });
+ const payload = payloadFor(message);
+ if (payload !== null) {
+ callback(payload);
+ }
+ return Promise.resolve(() => subscriptions.delete(id));
+ }
+ },
+ states: {
+ [LOCK_ENTITY_ID]: {
+ attributes: { friendly_name: 'Front Door' },
+ entity_id: LOCK_ENTITY_ID,
+ state: 'locked'
+ }
+ }
+};
+
+function mountCards() {
+ for (const slotNum of Object.keys(model.slots)) {
+ const card = document.createElement('lcm-slot');
+ card.setConfig({
+ code_display: 'masked',
+ config_entry_id: CONFIG_ENTRY_ID,
+ slot: Number(slotNum),
+ type: 'custom:lcm-slot'
+ });
+ card.hass = mockHass;
+ document.getElementById('masked-zone').appendChild(card);
+ }
+ const lockCodes = document.createElement('lcm-lock-codes');
+ lockCodes.setConfig({
+ code_display: 'masked',
+ lock_entity_id: LOCK_ENTITY_ID,
+ type: 'custom:lcm-lock-codes'
+ });
+ lockCodes.hass = mockHass;
+ document.getElementById('lock-codes-zone').appendChild(lockCodes);
+}
+
+function randomPin() {
+ return String(Math.floor(1000 + Math.random() * 9000));
+}
+
+function randomSlotNum() {
+ const nums = Object.keys(model.slots);
+ return Number(nums[Math.floor(Math.random() * nums.length)]);
+}
+
+const chaosHandlers = {
+ 'chaos-add-slot': () => {
+ const next = [1, 2, 3, 4, 5, 6].find((n) => !model.slots[n]);
+ if (next) {
+ model.slots[next] = {
+ active: true,
+ enabled: true,
+ inSync: false,
+ name: `User ${next}`,
+ pin: randomPin()
+ };
+ }
+ },
+ 'chaos-clear-slot': () => {
+ const nums = Object.keys(model.slots);
+ if (nums.length > 1) {
+ delete model.slots[randomSlotNum()];
+ }
+ },
+ 'chaos-external-pin': () => {
+ model.slots[randomSlotNum()].pin = randomPin();
+ },
+ 'chaos-rename': () => {
+ const slot = model.slots[randomSlotNum()];
+ // Digit-free label: a numeric revision could collide with a live
+ // secret PIN and falsely trip the no-leak property.
+ slot.name = `Renamed ${String(model.revision).replace(/\d/g, (d) => 'ABCDEFGHIJ'[Number(d)])}`;
+ },
+ 'chaos-toggle-active': () => {
+ const slot = model.slots[randomSlotNum()];
+ slot.active = !slot.active;
+ },
+ 'chaos-toggle-enabled': () => {
+ const slot = model.slots[randomSlotNum()];
+ slot.enabled = !slot.enabled;
+ },
+ 'chaos-toggle-suspended': () => {
+ model.suspended = !model.suspended;
+ },
+ 'chaos-toggle-sync': () => {
+ const slot = model.slots[randomSlotNum()];
+ slot.inSync = !slot.inSync;
+ }
+};
+
+for (const [id, handler] of Object.entries(chaosHandlers)) {
+ document.getElementById(id).addEventListener('click', () => {
+ handler();
+ pushAll();
+ });
+}
+
+mountCards();
diff --git a/pbt/harness/index.html b/pbt/harness/index.html
new file mode 100644
index 00000000..e98d72c6
--- /dev/null
+++ b/pbt/harness/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+ LCM Bombadil Harness
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pbt/run.mjs b/pbt/run.mjs
new file mode 100644
index 00000000..33d317fd
--- /dev/null
+++ b/pbt/run.mjs
@@ -0,0 +1,43 @@
+/**
+ * Orchestrates a Bombadil run: start the static server, explore, clean up.
+ * Env knobs: BOMBADIL_TIME_LIMIT (default 60s), BOMBADIL_HEADLESS=1,
+ * PBT_PORT (default 8199).
+ * Exit code follows bombadil: 0 = clean, 2 = property violation.
+ */
+import { spawn } from 'node:child_process';
+import { setTimeout as sleep } from 'node:timers/promises';
+
+const PORT = Number(process.env.PBT_PORT ?? 8199);
+const TIME_LIMIT = process.env.BOMBADIL_TIME_LIMIT ?? '60s';
+const HEADLESS = process.env.BOMBADIL_HEADLESS === '1' || process.env.CI === 'true';
+
+const server = spawn('node', ['pbt/serve.mjs'], { stdio: 'inherit' });
+await sleep(500);
+
+const args = [
+ 'browser',
+ 'test',
+ `http://127.0.0.1:${PORT}/pbt/harness/`,
+ 'pbt/spec.ts',
+ `--time-limit=${TIME_LIMIT}`,
+ '--exit-on-violation',
+ '--output-path=pbt-output',
+ '--output-path-overwrite'
+];
+if (HEADLESS) {
+ args.push('--headless');
+}
+if (process.env.CI === 'true') {
+ args.push('--no-sandbox');
+}
+
+const bombadil = spawn('node_modules/.bin/bombadil', args, { stdio: 'inherit' });
+const exitCode = await new Promise((resolve) => {
+ bombadil.on('error', (err) => {
+ console.error('failed to launch bombadil:', err);
+ resolve(1);
+ });
+ bombadil.on('exit', (code) => resolve(code ?? 1));
+});
+server.kill();
+process.exit(exitCode);
diff --git a/pbt/serve.mjs b/pbt/serve.mjs
new file mode 100644
index 00000000..c6e628c7
--- /dev/null
+++ b/pbt/serve.mjs
@@ -0,0 +1,50 @@
+/** Minimal static file server for the Bombadil harness (no dependencies). */
+import { createServer } from 'node:http';
+import { readFile } from 'node:fs/promises';
+import { extname, join, normalize } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url));
+const PORT = Number(process.env.PBT_PORT ?? 8199);
+const MIME = {
+ '.css': 'text/css',
+ '.html': 'text/html',
+ '.js': 'text/javascript',
+ '.json': 'application/json',
+ '.mjs': 'text/javascript'
+};
+
+const server = createServer(async (req, res) => {
+ const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
+ if (url.pathname === '/favicon.ico') {
+ res.writeHead(204);
+ res.end();
+ return;
+ }
+ let path = normalize(url.pathname).replace(/^(\.\.[/\\])+/, '');
+ if (path.endsWith('/')) {
+ path = `${path}index.html`;
+ }
+ // The cards navigate via history.pushState to virtual Home Assistant
+ // routes (e.g. /config/integrations/...), and a browser reload then
+ // requests that route from this server. Serve the harness page for any
+ // extensionless path — mirroring the real frontend's single-page-app
+ // routing — while keeping honest 404s for missing assets.
+ if (!extname(path)) {
+ path = '/pbt/harness/index.html';
+ }
+ try {
+ const body = await readFile(join(ROOT, path));
+ res.writeHead(200, {
+ 'Content-Type': MIME[extname(path)] ?? 'application/octet-stream'
+ });
+ res.end(body);
+ } catch {
+ res.writeHead(404);
+ res.end('not found');
+ }
+});
+
+server.listen(PORT, '127.0.0.1', () => {
+ console.log(`harness at http://127.0.0.1:${PORT}/pbt/harness/`);
+});
diff --git a/pbt/spec.ts b/pbt/spec.ts
new file mode 100644
index 00000000..58af9e94
--- /dev/null
+++ b/pbt/spec.ts
@@ -0,0 +1,140 @@
+/**
+ * Bombadil specification for the Lock Code Manager card harness.
+ *
+ * Run via `yarn test:pbt`. Extractors run inside the browser; they must
+ * walk shadow roots explicitly because Lit renders into (open) shadow DOM,
+ * which document-level queries and innerText do not pierce.
+ */
+/* eslint-disable no-underscore-dangle -- window.__lcmHarness is the harness's fixed extractor surface */
+import { always, eventually, now } from '@antithesishq/bombadil';
+import { actions, extract } from '@antithesishq/bombadil/browser';
+
+export * from '@antithesishq/bombadil/browser/defaults';
+
+/** Recursively collect visible text across light and shadow DOM. */
+function deepText(root: Element | ShadowRoot): string {
+ let text = '';
+ for (const el of root.querySelectorAll('*')) {
+ if (el.shadowRoot) {
+ // Join with a separator so text nodes straddling a shadow
+ // boundary can't fuse into a digit run that isn't really there.
+ text += `${deepText(el.shadowRoot)}\n`;
+ }
+ }
+ text += root.textContent ?? '';
+ return text;
+}
+
+/** Recursively collect elements matching a selector across shadow roots. */
+function deepQueryAll(root: Element | Document | ShadowRoot, selector: string): Element[] {
+ const found = [...root.querySelectorAll(selector)];
+ for (const el of root.querySelectorAll('*')) {
+ if (el.shadowRoot) {
+ found.push(...deepQueryAll(el.shadowRoot, selector));
+ }
+ }
+ return found;
+}
+
+declare global {
+ interface Window {
+ __lcmHarness?: {
+ model: {
+ revision: number;
+ slots: Record<
+ string,
+ {
+ active: boolean;
+ enabled: boolean;
+ inSync: boolean;
+ name: string;
+ pin: string;
+ }
+ >;
+ suspended: boolean;
+ };
+ secretPins: () => string[];
+ };
+ }
+}
+
+const maskedZoneText = extract((state) => {
+ const zone = state.document.querySelector('#masked-zone');
+ return zone ? deepText(zone) : '';
+});
+
+const lockCodesChipCount = extract((state) => {
+ const zone = state.document.querySelector('#lock-codes-zone');
+ return zone ? deepQueryAll(zone, '.slot-chip').length : -1;
+});
+
+const secretPins = extract((state) => state.window.__lcmHarness?.secretPins() ?? []);
+
+const modelSlotCount = extract(
+ (state) => Object.keys(state.window.__lcmHarness?.model.slots ?? {}).length
+);
+
+const modelNames = extract((state) =>
+ Object.values(state.window.__lcmHarness?.model.slots ?? {}).map((slot) => slot.name)
+);
+
+const cardText = extract((state) => deepText(state.document.body));
+
+/** Masked cards must never render a secret PIN as cleartext. */
+export const maskedPinNeverLeaks = always(() =>
+ secretPins.current.every(
+ (pin) => !new RegExp(`(?
+ lockCodesChipCount.current === -1 ? true : lockCodesChipCount.current === modelSlotCount.current
+);
+
+/** Subscription liveness: pushed names eventually appear in the DOM. */
+export const namesEventuallyRendered = always(() =>
+ eventually(() => modelNames.current.every((name) => cardText.current.includes(name))).within(
+ 10,
+ 'seconds'
+ )
+);
+
+const modelSuspended = extract((state) => state.window.__lcmHarness?.model.suspended ?? false);
+
+const suspendedBannerVisible = extract((state) => {
+ const zone = state.document.querySelector('#lock-codes-zone');
+ return zone ? deepQueryAll(zone, '.suspended-banner').length > 0 : false;
+});
+
+/** Closest drivable "unavailable treatment": suspended state shows its banner. */
+export const suspendedStateEventuallyShowsBanner = always(() =>
+ now(() => modelSuspended.current).implies(
+ eventually(() => suspendedBannerVisible.current || !modelSuspended.current).within(
+ 10,
+ 'seconds'
+ )
+ )
+);
+
+/** Click chaos-panel buttons and card-internal (shadow DOM) buttons. */
+const clickablePoints = extract((state) => {
+ const points: { name: string; x: number; y: number }[] = [];
+ for (const el of deepQueryAll(state.document, 'button')) {
+ const rect = el.getBoundingClientRect();
+ if (rect.width > 0 && rect.height > 0) {
+ points.push({
+ name: el.id || el.textContent?.trim().slice(0, 24) || 'button',
+ x: rect.left + rect.width / 2,
+ y: rect.top + rect.height / 2
+ });
+ }
+ }
+ return points;
+});
+
+export const clickButtons = actions(() =>
+ clickablePoints.current.map(({ name, x, y }) => {
+ return { Click: { name, point: { x, y } } };
+ })
+);
diff --git a/pbt/tsconfig.json b/pbt/tsconfig.json
new file mode 100644
index 00000000..62501829
--- /dev/null
+++ b/pbt/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "skipLibCheck": true
+ },
+ "include": ["spec.ts"]
+}
diff --git a/pyproject.toml b/pyproject.toml
index d2539dd5..b3c0c613 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -136,7 +136,7 @@ overgeneral-exceptions = ["BaseException", "Exception", "HomeAssistantError"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
-norecursedirs = [".git", "testing_config"]
+norecursedirs = [".git", "testing_config", ".hypothesis"]
[tool.mypy]
ignore_missing_imports = true
diff --git a/requirements_test.txt b/requirements_test.txt
index b295adad..33be3087 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -1,3 +1,4 @@
+hypothesis>=6.100
pylint-strict-informational>=0.1
pytest>=9.0.3
pytest-homeassistant-custom-component==0.13.348
diff --git a/tests/conftest.py b/tests/conftest.py
index 64f4cb0c..91c1ed77 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -4,9 +4,11 @@
from collections.abc import Generator
from datetime import timedelta
+import os
from typing import Any
from unittest.mock import patch
+from hypothesis import settings as hypothesis_settings
import pytest
from pytest_homeassistant_custom_component.common import (
MockConfigEntry,
@@ -37,6 +39,16 @@
pytest_plugins = ["pytest_homeassistant_custom_component"]
+# Hypothesis profiles: "dev" keeps the full-suite run within its
+# seconds budget; CI opts into deeper exploration via HYPOTHESIS_PROFILE=ci.
+# deadline=None in both: per-example deadlines flake under the HA test
+# harness's timing noise and CI runner variance.
+hypothesis_settings.register_profile("dev", max_examples=15, deadline=None)
+hypothesis_settings.register_profile(
+ "ci", max_examples=200, deadline=None, print_blob=True
+)
+hypothesis_settings.load_profile(os.environ.get("HYPOTHESIS_PROFILE", "dev"))
+
TEST_DOMAIN = "test"
diff --git a/tests/properties/__init__.py b/tests/properties/__init__.py
new file mode 100644
index 00000000..facfdd38
--- /dev/null
+++ b/tests/properties/__init__.py
@@ -0,0 +1 @@
+"""Property-based tests (Hypothesis)."""
diff --git a/tests/properties/test_credential_machine.py b/tests/properties/test_credential_machine.py
new file mode 100644
index 00000000..8615a1bf
--- /dev/null
+++ b/tests/properties/test_credential_machine.py
@@ -0,0 +1,176 @@
+"""Stateful property test for credential write orchestration.
+
+Drives the real BaseLock write path (rate limiting, duplicate detection,
+connection checks, WriteResult handling) and LockUsercodeUpdateCoordinator
+against MockLCMLock, comparing everything to a plain-dict oracle.
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+from hypothesis import strategies as st
+from hypothesis.stateful import RuleBasedStateMachine, invariant, rule
+import pytest
+from pytest_homeassistant_custom_component.common import (
+ MockConfigEntry,
+ async_test_home_assistant,
+)
+
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+
+from custom_components.lock_code_manager.const import DOMAIN
+from custom_components.lock_code_manager.domain.coordinator import (
+ LockUsercodeUpdateCoordinator,
+)
+from custom_components.lock_code_manager.domain.exceptions import (
+ DuplicateCodeError,
+ LockDisconnected,
+)
+
+from ..common import MockLCMLock
+
+PINS = st.text(alphabet="0123456789", min_size=4, max_size=8)
+SLOTS = st.integers(min_value=1, max_value=5)
+
+
+class CredentialMachine(RuleBasedStateMachine):
+ """Random interleavings of writes, deletes, faults, and refreshes."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(self.loop)
+ self._hass_cm = async_test_home_assistant(self.loop)
+ self.hass = self.loop.run_until_complete(self._hass_cm.__aenter__())
+
+ self.config_entry = MockConfigEntry(domain=DOMAIN)
+ self.config_entry.add_to_hass(self.hass)
+ ent_reg = er.async_get(self.hass)
+ dev_reg = dr.async_get(self.hass)
+ lock_entity = ent_reg.async_get_or_create(
+ "lock", "test", "pbt_lock", config_entry=self.config_entry
+ )
+ self.lock = MockLCMLock(self.hass, dev_reg, ent_reg, None, lock_entity)
+ self.lock.codes = {}
+ # Rate-limit delay between operations would dominate machine runtime.
+ self.lock._min_operation_delay = 0
+ # Machine correctness also assumes an example never spans the
+ # coordinator's 10-second request-refresh cooldown: past it, the
+ # deferred debounced refresh could freshen coordinator.data between a
+ # rule's _would_duplicate read and its assertion. Examples run in
+ # milliseconds, leaving orders of magnitude of margin.
+ self.coordinator = LockUsercodeUpdateCoordinator(
+ self.hass, self.lock, self.config_entry
+ )
+ self.lock.coordinator = self.coordinator
+ self._run(self.coordinator.async_refresh())
+
+ # Oracle: lock-truth we expect, slot -> pin.
+ self.expected: dict[int, str] = {}
+
+ def _run(self, coro):
+ return self.loop.run_until_complete(coro)
+
+ def _would_duplicate(self, slot: int, pin: str) -> bool:
+ # Mirror of the source _check_duplicate_code reads: coordinator.data,
+ # which may lag lock.codes until the next refresh.
+ return any(
+ other_slot != slot and credential.matches(pin)
+ for other_slot, credential in self.coordinator.data.items()
+ )
+
+ @rule(slot=SLOTS, pin=PINS)
+ def set_credential(self, slot: int, pin: str) -> None:
+ if self._would_duplicate(slot, pin):
+ before = dict(self.lock.codes)
+ with pytest.raises(DuplicateCodeError):
+ self._run(
+ self.lock.async_internal_set_usercode(
+ slot, pin, name=f"PBT user {slot}"
+ )
+ )
+ assert self.lock.codes == before
+ else:
+ already_set = self.lock.codes.get(slot) == pin
+ self._run(
+ self.lock.async_internal_set_usercode(
+ slot, pin, name=f"PBT user {slot}"
+ )
+ )
+ self.expected[slot] = pin
+ if not already_set:
+ # Names must reach the provider verbatim (tagging is a
+ # name-keyed-provider concern, not BaseLock's).
+ assert self.lock.service_calls["set_usercode"][-1] == (
+ slot,
+ pin,
+ f"PBT user {slot}",
+ )
+
+ @rule(slot=SLOTS)
+ def delete_credential(self, slot: int) -> None:
+ self._run(self.lock.async_internal_clear_usercode(slot))
+ self.expected.pop(slot, None)
+
+ @rule(slot=SLOTS)
+ def set_same_pin_is_no_change(self, slot: int) -> None:
+ if slot not in self.expected:
+ return
+ pin = self.expected[slot]
+ if self._would_duplicate(slot, pin):
+ # An external change may have copied this PIN onto another slot;
+ # the duplicate guard fires before the no-change shortcut.
+ return
+ calls_before = len(self.lock.service_calls["set_usercode"])
+ self._run(
+ self.lock.async_internal_set_usercode(slot, pin, name=f"PBT user {slot}")
+ )
+ assert len(self.lock.service_calls["set_usercode"]) == calls_before
+
+ @rule(slot=SLOTS, pin=PINS)
+ def external_change(self, slot: int, pin: str) -> None:
+ self.lock.codes[slot] = pin
+ self.expected[slot] = pin
+
+ @rule()
+ def refresh_converges_coordinator(self) -> None:
+ self._run(self.coordinator.async_refresh())
+ observed = {
+ slot: credential.readable_pin
+ for slot, credential in self.coordinator.data.items()
+ if credential.is_present
+ }
+ assert observed == self.expected
+
+ @rule(slot=SLOTS, pin=PINS)
+ def write_while_disconnected_fails_loud(self, slot: int, pin: str) -> None:
+ self.lock.set_connected(False)
+ before = dict(self.lock.codes)
+ try:
+ with pytest.raises(LockDisconnected):
+ self._run(
+ self.lock.async_internal_set_usercode(
+ slot, pin, name=f"PBT user {slot}"
+ )
+ )
+ assert self.lock.codes == before
+ finally:
+ self.lock.set_connected(True)
+
+ @invariant()
+ def lock_state_matches_oracle(self) -> None:
+ assert self.lock.codes == self.expected
+
+ def teardown(self) -> None:
+ async def _shutdown() -> None:
+ await self.coordinator.async_shutdown()
+ await self.hass.async_stop(force=True)
+ await self._hass_cm.__aexit__(None, None, None)
+
+ self.loop.run_until_complete(_shutdown())
+ self.loop.close()
+ asyncio.set_event_loop(None)
+
+
+TestCredentialMachine = CredentialMachine.TestCase
diff --git a/tests/properties/test_in_sync.py b/tests/properties/test_in_sync.py
new file mode 100644
index 00000000..71bf1230
--- /dev/null
+++ b/tests/properties/test_in_sync.py
@@ -0,0 +1,108 @@
+"""Property-based tests for SlotSyncManager.calculate_in_sync."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from hypothesis import given, strategies as st
+
+from homeassistant.const import STATE_OFF, STATE_ON
+
+from custom_components.lock_code_manager.domain.models import SlotCredential
+from custom_components.lock_code_manager.domain.sync import SlotState, SlotSyncManager
+
+PINS = st.text(alphabet="0123456789", min_size=4, max_size=8)
+LAST_SET = st.one_of(st.none(), PINS)
+CREDENTIALS = st.one_of(
+ st.none(),
+ st.just(SlotCredential.empty()),
+ st.just(SlotCredential.unreadable()),
+ PINS.map(SlotCredential.known),
+)
+SLOT_STATES = st.builds(
+ SlotState,
+ active_state=st.sampled_from([STATE_ON, STATE_OFF]),
+ pin_state=PINS,
+ name_state=st.one_of(st.none(), st.text(max_size=20)),
+ code_state=st.one_of(st.just(""), PINS),
+ coordinator_code=CREDENTIALS,
+)
+
+
+def _manager(*, verified: bool, last_set_pin: str | None) -> SlotSyncManager:
+ # __new__ skips the heavyweight __init__ (hass, registries, entities);
+ # calculate_in_sync only touches these three attributes.
+ manager = SlotSyncManager.__new__(SlotSyncManager)
+ manager._slot_num = 1
+ manager._coordinator = SimpleNamespace(is_verified=lambda slot_num: verified)
+ manager._last_set_pin = last_set_pin
+ return manager
+
+
+@given(slot_state=SLOT_STATES, last_set_pin=LAST_SET)
+def test_unverified_slot_is_never_in_sync(
+ slot_state: SlotState, last_set_pin: str | None
+) -> None:
+ """An optimistic write awaiting confirmation can never read as in sync."""
+ manager = _manager(verified=False, last_set_pin=last_set_pin)
+ assert manager.calculate_in_sync(slot_state) is False
+
+
+@given(pin=PINS, credential_pin=PINS, last_set_pin=LAST_SET)
+def test_active_readable_credential_syncs_iff_pin_matches(
+ pin: str, credential_pin: str, last_set_pin: str | None
+) -> None:
+ """Active slot with a readable code: in sync exactly when PINs match."""
+ manager = _manager(verified=True, last_set_pin=last_set_pin)
+ state = SlotState(STATE_ON, pin, None, "", SlotCredential.known(credential_pin))
+ assert manager.calculate_in_sync(state) is (pin == credential_pin)
+
+
+@given(pin=PINS, last_set_pin=LAST_SET)
+def test_active_empty_credential_trusts_recent_set_only(
+ pin: str, last_set_pin: str | None
+) -> None:
+ """Active + lock reports empty: in sync only if we just set this exact PIN."""
+ manager = _manager(verified=True, last_set_pin=last_set_pin)
+ state = SlotState(STATE_ON, pin, None, "", SlotCredential.empty())
+ assert manager.calculate_in_sync(state) is (
+ last_set_pin is not None and pin == last_set_pin
+ )
+
+
+@given(pin=PINS, last_set_pin=LAST_SET)
+def test_active_unreadable_credential_compares_last_set(
+ pin: str, last_set_pin: str | None
+) -> None:
+ """Active + write-only code: in sync iff configured PIN equals last set."""
+ manager = _manager(verified=True, last_set_pin=last_set_pin)
+ state = SlotState(STATE_ON, pin, None, "", SlotCredential.unreadable())
+ assert manager.calculate_in_sync(state) is (pin == last_set_pin)
+
+
+@given(pin=PINS, code=st.one_of(st.just(""), PINS), last_set_pin=LAST_SET)
+def test_active_without_coordinator_data_falls_back_to_code_sensor(
+ pin: str, code: str, last_set_pin: str | None
+) -> None:
+ """No coordinator data: the code sensor entity is the comparison source."""
+ manager = _manager(verified=True, last_set_pin=last_set_pin)
+ state = SlotState(STATE_ON, pin, None, code, None)
+ assert manager.calculate_in_sync(state) is (pin == code)
+
+
+@given(slot_state=SLOT_STATES, last_set_pin=LAST_SET)
+def test_inactive_slot_syncs_iff_lock_side_empty(
+ slot_state: SlotState, last_set_pin: str | None
+) -> None:
+ """Inactive slot: in sync exactly when the lock side shows no code."""
+ manager = _manager(verified=True, last_set_pin=last_set_pin)
+ state = SlotState(
+ STATE_OFF,
+ slot_state.pin_state,
+ slot_state.name_state,
+ slot_state.code_state,
+ slot_state.coordinator_code,
+ )
+ credential = state.coordinator_code
+ expected = credential.is_empty if credential is not None else state.code_state == ""
+ assert manager.calculate_in_sync(state) is expected
diff --git a/tests/properties/test_pin_generator.py b/tests/properties/test_pin_generator.py
new file mode 100644
index 00000000..d2bd2c3e
--- /dev/null
+++ b/tests/properties/test_pin_generator.py
@@ -0,0 +1,62 @@
+"""Property-based tests for random PIN generation."""
+
+from __future__ import annotations
+
+from hypothesis import given, strategies as st
+import pytest
+
+from custom_components.lock_code_manager.domain.pin_generator import (
+ MAX_PIN_LENGTH,
+ MIN_PIN_LENGTH,
+ generate_pin,
+ is_unsafe_pin,
+)
+
+LENGTHS = st.integers(min_value=MIN_PIN_LENGTH, max_value=MAX_PIN_LENGTH)
+DIGITS = "0123456789"
+
+
+@given(length=LENGTHS)
+def test_generate_pin_returns_safe_digits_of_requested_length(length: int) -> None:
+ """Generated PINs are always the right length, numeric, and never unsafe."""
+ pin = generate_pin(length)
+ assert len(pin) == length
+ assert pin.isdigit()
+ assert not is_unsafe_pin(pin)
+
+
+@given(
+ length=st.integers(min_value=-100, max_value=100).filter(
+ lambda n: not MIN_PIN_LENGTH <= n <= MAX_PIN_LENGTH
+ )
+)
+def test_generate_pin_rejects_out_of_range_lengths(length: int) -> None:
+ """Lengths outside [MIN_PIN_LENGTH, MAX_PIN_LENGTH] raise ValueError."""
+ with pytest.raises(ValueError):
+ generate_pin(length)
+
+
+@given(digit=st.sampled_from(DIGITS), length=LENGTHS)
+def test_all_same_digits_is_unsafe(digit: str, length: int) -> None:
+ """Any repdigit PIN is rejected as unsafe."""
+ assert is_unsafe_pin(digit * length)
+
+
+@given(
+ base=st.text(alphabet=DIGITS, min_size=1, max_size=4),
+ repeats=st.integers(min_value=2, max_value=4),
+)
+def test_repeating_subsequence_is_unsafe(base: str, repeats: int) -> None:
+ """Any PIN that is a shorter block repeated (1212, 123123, ...) is unsafe."""
+ assert is_unsafe_pin(base * repeats)
+
+
+@given(
+ start=st.integers(min_value=0, max_value=9),
+ step=st.sampled_from([1, -1]),
+ length=LENGTHS,
+)
+def test_sequential_with_wrap_is_unsafe(start: int, step: int, length: int) -> None:
+ """Fully ascending/descending runs, including 9->0 / 0->9 wrap, are unsafe."""
+ pin = "".join(str((start + i * step) % 10) for i in range(length))
+ assert is_unsafe_pin(pin)
diff --git a/tests/properties/test_tag_codec.py b/tests/properties/test_tag_codec.py
new file mode 100644
index 00000000..e36cecbf
--- /dev/null
+++ b/tests/properties/test_tag_codec.py
@@ -0,0 +1,82 @@
+"""Property-based tests for the provider slot-tag codec."""
+
+from __future__ import annotations
+
+from hypothesis import given, strategies as st
+
+from custom_components.lock_code_manager.providers._util import (
+ make_compact_tagged_name,
+ make_tagged_name,
+ parse_slot_num,
+ parse_tag,
+)
+
+SLOT_NUMS = st.integers(min_value=1, max_value=9999)
+
+# Documented contract edges (see _util.py regexes): the canonical pattern
+# strips leading whitespace after "lcm::" via \s*, and "." never
+# crosses a newline — so names starting with whitespace or containing
+# newlines cannot round-trip by design. The strategy encodes that contract.
+NAMES = st.text(min_size=1, max_size=40).filter(
+ lambda s: "\n" not in s and not s[0].isspace()
+)
+
+
+@given(slot=SLOT_NUMS, name=NAMES)
+def test_canonical_round_trip(slot: int, name: str) -> None:
+ """Canonical lcm:: encodes and decodes losslessly."""
+ assert parse_tag(make_tagged_name(slot, name)) == (slot, name)
+
+
+@given(slot=SLOT_NUMS)
+def test_default_name_round_trip(slot: int) -> None:
+ """Omitted name falls back to the documented 'Code Slot N' display."""
+ assert parse_tag(make_tagged_name(slot)) == (slot, f"Code Slot {slot}")
+
+
+@given(slot=SLOT_NUMS)
+def test_compact_round_trip(slot: int) -> None:
+ """Compact lcm preserves the slot binding with empty display."""
+ assert parse_tag(make_compact_tagged_name(slot)) == (slot, "")
+
+
+@given(slot=SLOT_NUMS)
+def test_slot_only_round_trip(slot: int) -> None:
+ """Bare digits parse as a slot tag (documented, intentionally ambiguous)."""
+ assert parse_tag(str(slot)) == (slot, "")
+
+
+@given(slot=SLOT_NUMS, name=NAMES)
+def test_legacy_format_still_parses(slot: int, name: str) -> None:
+ """Read-only legacy [LCM:] is still recognized."""
+ assert parse_tag(f"[LCM:{slot}] {name}") == (slot, name)
+
+
+@given(name=st.text(max_size=60))
+def test_parse_tag_is_total(name: str) -> None:
+ """parse_tag never raises; non-tags come back unchanged."""
+ slot, friendly = parse_tag(name)
+ if slot is None:
+ assert friendly == name
+ else:
+ assert isinstance(slot, int)
+
+
+@given(
+ value=st.one_of(
+ st.none(),
+ st.booleans(),
+ st.integers(),
+ st.floats(allow_nan=True, allow_infinity=True),
+ st.text(),
+ st.lists(st.integers(), max_size=3),
+ )
+)
+def test_parse_slot_num_is_total(value: object) -> None:
+ """parse_slot_num never raises; bools are rejected, ints pass through."""
+ result = parse_slot_num(value)
+ assert result is None or isinstance(result, int)
+ if isinstance(value, bool):
+ assert result is None
+ elif isinstance(value, int):
+ assert result == value
diff --git a/yarn.lock b/yarn.lock
index f700229b..4a39ac6c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7,6 +7,11 @@
resolved "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz"
integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==
+"@antithesishq/bombadil@0.6.1":
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/@antithesishq/bombadil/-/bombadil-0.6.1.tgz#89c442de80879940497e31e53ae98c57f019b9bd"
+ integrity sha512-d1iufG3MI7gSMSiSmMeNdcMW+qR0yQXL2zdkVynC3n3DYgFJYlYXKUQzygmqU12m4RWlR5iOdQU1hsx5UT6+IA==
+
"@asamuzakjp/css-color@^6.0.5":
version "6.0.5"
resolved "https://registry.yarnpkg.com/@asamuzakjp/css-color/-/css-color-6.0.5.tgz#f4edcf3295e5fdee655fce3bcd11ce09411089d8"