From e9d7d1ef2c9b961f0a3ad31dc49198c5923c4504 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Tue, 19 May 2026 09:53:11 +0300 Subject: [PATCH 1/2] Add some more integration tests for trigger evals --- .../expressions/test_eval_triggers.py | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/integration/expressions/test_eval_triggers.py b/tests/integration/expressions/test_eval_triggers.py index 3ae8a581..b5858969 100644 --- a/tests/integration/expressions/test_eval_triggers.py +++ b/tests/integration/expressions/test_eval_triggers.py @@ -1,7 +1,23 @@ import asyncio +import pytest + from qtoggleserver.conf import settings from qtoggleserver.core import main +from qtoggleserver.core import ports as core_ports +from qtoggleserver.core.events import handlers as event_handlers +from tests.unit.qtoggleserver.mock.ports import MockNumberPort + + +@pytest.fixture +def with_attr_change_handler(): + """Register the main module's attr-change event handler for the duration of the test.""" + event_handlers._registered_handlers.append(main._attr_change_handler) + main._attr_change_handler._pending.clear() + yield + if main._attr_change_handler in event_handlers._registered_handlers: + event_handlers._registered_handlers.remove(main._attr_change_handler) + main._attr_change_handler._pending.clear() async def test_eval_trigger_set_expression(mock_num_port1, mock_num_port2, mocker): @@ -81,3 +97,88 @@ async def test_eval_trigger_port_enabled(mock_num_port1, mocker): await main.read_ports() await asyncio.sleep(settings.core.tick_interval / 1000) mock_num_port1.transform_and_write_value.assert_called_once_with(60) + + +async def test_eval_trigger_port_attr_change(mock_num_port1, mock_num_port2, with_attr_change_handler, mocker): + """Should trigger expression evaluation when a port's attribute changes.""" + + mock_num_port2.set_writable(True) + mock_num_port2.set_expression("$nid1:enabled") + mocker.patch.object(mock_num_port2, "transform_and_write_value") + + # Changing port1's display_name fires a debounced PortUpdate event + await mock_num_port1.set_attr("display_name", "updated") + # Wait for the debounced _after_set_attr task to run and dispatch the PortUpdate event + await asyncio.sleep(settings.core.tick_interval / 1000) + + await main.read_ports() + await asyncio.sleep(settings.core.tick_interval / 1000) + mock_num_port2.transform_and_write_value.assert_called_once_with(1) + + +async def test_eval_trigger_port_add(mock_num_port1, with_attr_change_handler, mocker): + """Should trigger expression evaluation when a new port is added.""" + + mock_num_port1.set_writable(True) + mock_num_port1.set_last_read_value(0) + # Expression depends on an attribute of nid3, which doesn't exist yet + mock_num_port1.set_expression("$nid3:enabled") + mocker.patch.object(mock_num_port1, "transform_and_write_value") + + # Loading the port fires a PortAdd event for nid3 → $nid3: added to pending attr-change deps + nid3 = (await core_ports.load([{"driver": MockNumberPort, "port_id": "nid3", "value": None}]))[0] + try: + await nid3.enable() + + await main.read_ports() + await asyncio.sleep(settings.core.tick_interval / 1000) + mock_num_port1.transform_and_write_value.assert_called_once_with(1) + finally: + await nid3.remove(persisted_data=False) + + +async def test_eval_trigger_port_remove(mock_num_port1, with_attr_change_handler, mocker): + """Should trigger expression evaluation when a port is removed.""" + + # Load a temporary port that will be removed during the test + nid3 = (await core_ports.load([{"driver": MockNumberPort, "port_id": "nid3", "value": None}]))[0] + await nid3.enable() + + mock_num_port1.set_writable(True) + # Expression depends on an attribute of nid3; removing nid3 must trigger re-evaluation + mock_num_port1.set_expression("$nid3:enabled") + spy = mocker.spy(mock_num_port1, "eval_and_push_write") + + # Removing the port fires a PortRemove event → $nid3: added to pending attr-change deps + await nid3.remove(persisted_data=False) + + await main.read_ports() + spy.assert_called_once() + + +async def test_eval_trigger_device_attr_change(mock_num_port1, with_attr_change_handler, mocker): + """Should trigger expression evaluation when a device attribute changes.""" + + from qtoggleserver.core.device import attrs as device_attrs + from qtoggleserver.core.device import events as device_events + + original_display_name = device_attrs.display_name + try: + mock_num_port1.set_writable(True) + mock_num_port1.set_last_read_value(0) + # Expression depends on the device's display_name attribute + mock_num_port1.set_expression("#:display_name") + mocker.patch.object(mock_num_port1, "transform_and_write_value") + + # Set a non-empty display_name so the expression evaluates to 1 (truthy string → 1) + device_attrs.display_name = "test" + device_attrs.invalidate_attrs() + # Fire the DeviceUpdate event (normally emitted by the device attrs update loop) + await device_events.trigger_update() + + await main.read_ports() + await asyncio.sleep(settings.core.tick_interval / 1000) + mock_num_port1.transform_and_write_value.assert_called_once_with(1) + finally: + device_attrs.display_name = original_display_name + device_attrs.invalidate_attrs() From 949fc012475212ff5be99d04bee2829dc30c03f7 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Tue, 19 May 2026 09:55:23 +0300 Subject: [PATCH 2/2] Add more details to copilot-instructions.md --- .github/copilot-instructions.md | 38 ++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7e2df2b7..a4edc4c1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,5 +1,9 @@ # qToggleServer – Copilot Instructions +## Environment + +The Python virtualenv lives at `.venv` in the repo root. Activate it with `source .venv/bin/activate` before running any Python commands. If the virtualenv exists, do not attempt to install `uv`, `pip`, or other tools globally — they are already available inside `.venv`. + ## Commands ```bash @@ -12,9 +16,16 @@ ruff format qtoggleserver # format Pre-commit hooks run `ruff check` and `ruff format` automatically. +Frontend (run from `qtoggleserver/frontend/`): +```bash +npx webpack --mode production # build — output goes to dist/ +npx eslint js/ # lint JS +QUI_PATH=/path/to/qui npx webpack --mode development # dev build with local QUI checkout +``` + ## Architecture -qToggleServer is a [qToggle protocol](https://github.com/qtoggle/docs) server. The main concepts: +qToggleServer is a [qToggle protocol](https://github.com/qtoggle/docs) server. The HTTP API implementation derives largely from the [qToggle API 1.3 spec](https://github.com/qtoggle/docs/wiki/The-qToggle-API-1.3). The main concepts: - **Ports** (`qtoggleserver/core/ports.py`) — the central abstraction. A port has a boolean or number value and a set of attributes. `BasePort` is the base class; hardware drivers subclass it. - **Peripherals** (`qtoggleserver/peripherals/`) — hardware devices that own one or more ports. Subclass `Peripheral` and implement `make_port_args()` to declare ports. Peripherals may run blocking I/O in a `ThreadedRunner`. @@ -143,3 +154,28 @@ async def get_something(request: core_api.APIRequest) -> dict: **Access levels:** `ACCESS_LEVEL_NONE=0`, `ACCESS_LEVEL_VIEWONLY=10`, `ACCESS_LEVEL_NORMAL=20`, `ACCESS_LEVEL_ADMIN=30`. **Input validation** — use `core_api_schema.validate(data, json_schema, ...)` (wraps `jsonschema`). Pass `unexpected_field_code` to customise the error code for unrecognised fields. + +## Frontend + +The frontend lives at `qtoggleserver/frontend/` and is a single-page application built with vanilla ES2018 modules, webpack 4, and LESS. + +**UI framework** — [`@qtoggle/qui`](https://github.com/qtoggle/qui) (QUI). Imported via the `$qui/` path alias. The webpack config delegates to `qui/webpack/webpack-common.js`; set `QUI_PATH` env var to use a local checkout instead of the npm package. + +**Path aliases:** +- `$qui/` → QUI framework (`node_modules/@qtoggle/qui/js/`) +- `$app/` → the app's own `js/` directory +- `$node/` → `node_modules/` + +**Directory layout (`js/`):** +- `api/` — thin wrappers around every HTTP endpoint (`ports.js`, `devices.js`, `notifications.js`, etc.). `base.js` handles request signing, time-skew, and error normalisation into `APIError`. +- `cache.js` — loads and caches device attributes and port list; exposes `Cache.load()`, `Cache.reload()`, `Cache.getMainDevice()`, etc. +- `events.js` — subscribes to the server notifications (listen) stream and dispatches events to registered listeners. +- `auth.js` — manages the current access level; exposes `Auth.init()` and access-level-change signals. +- `dashboard/`, `ports/`, `devices/`, `peripherals/`, `settings/`, `login/` — one QUI *section* per area of the UI. +- `common/` — shared form mixins and page components (backup/restore, firmware update, reboot, etc.). +- `widgets/` — reusable QUI widgets specific to this app. + +**Key conventions:** +- 4-space indent, single quotes, no semicolons, 120-char line limit (enforced by ESLint via `eslint.config.mjs`). +- Sections are registered with `Sections.register(SectionClass)` in `index.js`; each section class lives in its own subdirectory and is a QUI `Section` subclass. +- Server-side frontend events (`DashboardUpdateEvent`, etc.) are defined in `qtoggleserver/frontend/events.py` and extend `core_events.Event`; the frontend's `events.js` handles them via the notifications API.