Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions client/python/tests/e2e/cases/test_animations.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ def test_anim_flash_state_transitions(
)
assert conn.animations.query(a).state == AnimationState.IDLE

# Single source of truth: query() and list_animations() return the same
# canonical type_name (the serde config tag), sent verbatim by the server.
assert conn.animations.query(a).type_name == "FlashForNFrames"
listed = next(i for i in conn.animations.list_animations() if i.handle == a)
assert listed.type_name == conn.animations.query(a).type_name

conn.animations.arm(a)
assert conn.animations.query(a).state in (
AnimationState.ARMED,
Expand Down
2 changes: 1 addition & 1 deletion client/python/tests/e2e/cases/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def test_retrieve_returns_valid_json(conn: Connection) -> None:
raw = conn.config.retrieve()
assert isinstance(raw, str) and len(raw) > 0
data = json.loads(raw)
assert data["version"] == 1
assert data["version"] == 2
assert "scene" in data
assert "io" in data

Expand Down
6 changes: 4 additions & 2 deletions client/python/vstimd/animations/animations_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,14 @@ def query(self, handle: AnimationHandle) -> AnimationDetails:
))
r = resp.query_animation_response
p = r.params
type_name = p.WhichOneof("body") or "unknown"
# `type_name` is the server's canonical tag (the Rust variant name, which
# is also the serde config-file tag) — sent verbatim in both list and
# query, so it matches list_animations() and never drifts from configs.
return AnimationDetails(
handle=AnimationHandle(r.handle),
name=p.name,
state=AnimationState(r.state),
type_name=type_name,
type_name=r.type_name,
stimuli=tuple(StimulusHandle(s) for s in p.stimuli),
final_action=FinalAction(p.final_action_mask),
)
Expand Down
6 changes: 5 additions & 1 deletion client/web/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
SERVER_ROOT := ../..
# Window size for `make dev` (override: make dev WINDOW=1920x1080)
WINDOW ?= 1280x720
# Host the Vite dev UI binds to. Default 0.0.0.0 so the UI is reachable from other
# machines on the LAN (browse http://<this-device-ip>:5173). Override for loopback
# only: make dev UI_HOST=127.0.0.1
UI_HOST ?= 0.0.0.0

.PHONY: install gen dev dev-null build typecheck test-e2e test-ui test clean

Expand Down Expand Up @@ -38,7 +42,7 @@ dev-null: gen
_run:
@echo "vstimd: starting server + web dev UI (Ctrl-C stops both)…"
@$(SERVER_ROOT)/target/release/vstimd $(SERVER_ARGS) & server=$$!; \
npm run dev -- --host 127.0.0.1 & ui=$$!; \
npm run dev -- --host $(UI_HOST) & ui=$$!; \
trap "kill -INT $$server 2>/dev/null; kill -TERM $$ui 2>/dev/null" INT TERM; \
wait $$ui; kill -INT $$server 2>/dev/null; wait $$server 2>/dev/null || true

Expand Down
34 changes: 27 additions & 7 deletions client/web/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,20 @@ React UI ──uses──▶ client library (public API) ──wraps──▶

## TODO (roughly in priority order)

1. **Expand the client API** to full parity with the Python client / proto:
- stimuli: DONE — `createEllipse`, grating (`conn.stimuli.grating`: create +
1. **Expand the client API** to full parity with the Python client / proto: DONE.
- stimuli: `createEllipse`, grating (`conn.stimuli.grating`: create +
sf/contrast/phase/drift/opacity/waveform/mask/fore+backColor), text
(`conn.stimuli.text`: create/setText/setColor), and shape setters
(setOrientation, setRectSize/CircleRadius/EllipseSize, setFillColor, setAlpha).
- TODO: remaining generic setters (outlineColor/Width, drawMode, drawOrder),
and `conn.vtl` (list/name/set/toggle lines), `conn.animations`
(create/arm/disarm/delete/list), `conn.config` (list/load/save/retrieve).
(`conn.stimuli.text`: create/setText/setColor), shape setters
(setOrientation, setRectSize/CircleRadius/EllipseSize, setFillColor, setAlpha),
and the remaining generic setters (setName, setDrawMode, setOutlineColor,
setOutlineWidth, bringToFront/sendToBack/swapDrawOrder).
- `conn.vtl`: list/name/set/toggle/clear lines.
- `conn.animations`: create (all 7 types) / arm / disarm / delete / list / query,
with `*Frames`|`*Ms` conversion via a cached server frame rate.
- `conn.config`: list / load / save / retrieve / upload.
- JSON Schema for config (schemars server-side, file export + `conn.config.schema()`):
DEFERRED — do this right before the Config UI panel (step 2) so the panel is
schema-driven. See "Known issues" for the related server gaps.
2. **UI panels** to match the egui overlay: VTL, Animations, System
(background/photodiode/deferred), Config (save/load), Log
(snapshot.commandLog + server log). Creation dialogs for all stimulus types.
Expand All @@ -72,6 +78,20 @@ React UI ──uses──▶ client library (public API) ──wraps──▶

## Known issues

- **Draw-order commands unimplemented server-side**: `bringToFront` / `sendToBack`
/ `swapDrawOrder` are wired in both clients but the server returns `NotSupported`
(brain-daemons/vstimd#43). The web e2e asserts the `NotSupported` gap for now.
- **Animation `typeName` casing**: the server reports `type_name` in PascalCase
(`MoveAlongPath2D`) from `list()`, while `query()` derives it from the proto
oneof (`moveAlongPath2d`). The web client normalizes both to the proto camelCase
form (`AnimationTypeName`) so they always agree.
- **Animation `type_name` is single-source**: the canonical tag is the Rust enum
variant name (e.g. `MoveAlongPath2D`), which is also the serde config-file tag.
The server sends it verbatim in both `list()` and `query()` (a guard test locks
`type_name()` to the serde tag); all clients pass it through unchanged.
- **Prod config dir**: default is the cwd (good for dev/tests). For systemd, use
`StateDirectory=vstimd` + `--config-dir ${STATE_DIRECTORY}/configs`
(`/var/lib/vstimd/configs`) — runtime-mutable state, not `/etc`. (See step 5.)
- **Shutdown segfault**: vstimd core-dumps on Ctrl-C/SIGTERM with the *windowed*
backend (both web + ZMQ "shutting down" log lines print first, so it's in
teardown after `backend.run()` returns). Not reproducible/triagable without a
Expand Down
55 changes: 55 additions & 0 deletions client/web/playwright/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const BACKEND = "ws://127.0.0.1:8138";
test.beforeEach(async () => {
const conn = await Connection.connect(BACKEND);
await conn.system.deleteAll();
for (const a of await conn.animations.list()) await conn.animations.delete(a.handle);
conn.close();
});

Expand All @@ -35,6 +36,60 @@ test("creates a stimulus", async ({ page }) => {
await expect(row).toContainText("0, 0");
});

test("toggles a VTL bit in the binary grid", async ({ page }) => {
// Register a named input line server-side, then load the UI.
const conn = await Connection.connect(BACKEND);
await conn.vtl.setName(0, 1, "input", "trig");
await conn.vtl.setInput("trig", false); // known starting level
conn.close();

await page.goto("/");
await expect(page.getByText("connected")).toBeVisible();

// Every bit is a clickable cell; the named input bit starts low (0).
const cell = page.getByTitle("input bank 0 bit 1: trig");
await expect(cell).toHaveText("0");

// Clicking toggles the line high (reconciled via the next snapshot).
await cell.click();
await expect(cell).toHaveText("1");
});

test("lists an animation and arms it", async ({ page }) => {
// Create a stimulus + a flash animation server-side, then load the UI.
const conn = await Connection.connect(BACKEND);
const h = await conn.stimuli.shapes.createRect({ name: "anim-rect" });
await conn.animations.flash(h, { durationFrames: 30, name: "fl" });
conn.close();

await page.goto("/");
await expect(page.getByText("connected")).toBeVisible();

// The animation appears in the panel (polled) with its canonical type tag.
const row = page.locator("tr", { hasText: "fl" });
await expect(row).toContainText("FlashForNFrames");
await expect(row).toContainText("idle");

// Arming starts it; the polled state leaves idle (armed → running → done).
await row.getByRole("button", { name: "arm", exact: true }).click();
await expect(row).not.toContainText("idle");
});

test("system: Hide all disables every stimulus", async ({ page }) => {
const conn = await Connection.connect(BACKEND);
await conn.stimuli.shapes.createRect({ name: "sys-rect" });
conn.close();

await page.goto("/");
await expect(page.getByText("connected")).toBeVisible();

const checkbox = page.locator("tr", { hasText: "sys-rect" }).locator("input[type=checkbox]");
await expect(checkbox).toBeChecked();

await page.getByRole("button", { name: "Hide all" }).click();
await expect(checkbox).not.toBeChecked(); // reconciled via the next snapshot
});

test("drag on the map moves the stimulus (RF mapping)", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("connected")).toBeVisible();
Expand Down
Loading
Loading