* claude-companion: v1.3.0 — headless aggregator, user settings, sessions panel Catalog-side update for lowcache/claude-companion, from 1.0.1 to 1.3.0. Architecture: the pulse aggregator moved out of the bar widget into a headless [[service]] (pulse-svc.luau). Capture no longer depends on the bar dot being placed — the service starts with the shell and listens regardless of surfaces, retiring the plugin's old "pulse must sit on a bar" deployment invariant. The bar widget and desktop orb are now independent subscribers of the claude.pulse rollup, rendering only. Noctalia 5 beta also fixed the older limitation where bar widgets did not receive state.watch callbacks, so the bar dot is event-driven like the orb; both docs are updated accordingly. New: a `sessions` panel on right-click of the pulse (left-click still opens the answer panel) — one row per live session with state, model and token burn, plus a Retire control for a session whose SessionEnd hook never fired and which would otherwise sit at idle indefinitely. It introduces no new IPC verb: only the trailing `session` payload field is read for routing, so `session_end` with `,,,,,<sid>` is already a well-formed single-session retire. PROTOCOL.md now documents that property so any adapter can use it. Session ids are allowlisted before reaching a shell command. New: three animation settings (breath_speed, pulse_glow_floor, orb_swell), each declaring an explicit `step` — an omitted step defaults to 1.0 in the manifest parser, which collapses a fractional range to a couple of preset stops instead of a slider. plugin_api stays at 3. Everything used here is ungated at that level, and the contributing guidance is to raise it only when adopting a capability from a newer level. Verified against the installed build rather than assumed. Also ships tests/manifest_spec.py, which pins the settings contract: every numeric setting must declare an explicit step finer than its range, defaults must land on a step boundary, label/description must use the *_key form, and every key must resolve in translations/en.json. Neither the linter nor a widget spec can catch a bad step, which is how the slider bug shipped in the first place. Validated: catalog validator 54/54 exit 0, its own 54 self-tests pass, and the plugin's suite (5 luau + shim + manifest) is green. Live-tested on niri against Noctalia 5 beta; the compositor shim is unchanged in this update. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * claude-companion: tint the sessions retire control, fix singular header Follow-up on the v1.3.0 submission from live review: the retire button carried no variant and rendered at the background colour; a single session read "1 sessions". The panel root stays unfilled by design — noctalia panels are translucent under the glass style, so the backdrop is the shell's, not the plugin's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: lowcache <drawpdeadredd@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
136 lines
5.9 KiB
Python
136 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Manifest invariants for plugin.toml — the settings contract.
|
|
|
|
Covers the one class of defect neither `noctalia plugins lint` nor the luau specs
|
|
can see: `lint` only cross-checks declared settings against getConfig() calls, and
|
|
the widget code never observes a slider's step, so a wrong step is invisible to
|
|
both. Run: python3 tests/manifest_spec.py
|
|
|
|
The load-bearing invariant is STEP. Noctalia's manifest parser defaults an
|
|
omitted step to 1.0 (plugin_manifest.h: `double step = 1.0`), so a fractional
|
|
range silently degenerates to min + n*1.0 clamped to max — a handful of preset
|
|
stops instead of a slider. Shipped exactly that way once: pulse_glow_floor
|
|
(0.0-0.9) could only reach 0.0 and 0.9. Every double MUST declare step.
|
|
"""
|
|
import os
|
|
import unittest
|
|
|
|
try:
|
|
import tomllib
|
|
except ModuleNotFoundError: # py<3.11
|
|
import tomli as tomllib # type: ignore
|
|
|
|
ROOT = os.environ.get("PLUGIN_ROOT") or os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
NUMERIC = ("double", "number", "float")
|
|
|
|
|
|
def _settings(manifest):
|
|
"""Every declared setting, plugin-level and entry-level, as (origin, dict)."""
|
|
out = [("[[setting]]", s) for s in manifest.get("setting", [])]
|
|
for kind in ("widget", "desktop_widget", "panel", "service", "launcher"):
|
|
for entry in manifest.get(kind, []):
|
|
for s in entry.get("setting", []):
|
|
out.append((f"[[{kind}.setting]] {entry.get('id', '?')}", s))
|
|
return out
|
|
|
|
|
|
class Manifest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
with open(os.path.join(ROOT, "plugin.toml"), "rb") as fh:
|
|
cls.manifest = tomllib.load(fh)
|
|
cls.settings = _settings(cls.manifest)
|
|
|
|
def test_has_settings(self):
|
|
self.assertTrue(self.settings, "expected at least one declared setting")
|
|
|
|
def test_every_numeric_declares_explicit_step(self):
|
|
"""The regression guard. An omitted step means 1.0, not 'continuous'."""
|
|
for origin, s in self.settings:
|
|
if s.get("type") in NUMERIC:
|
|
with self.subTest(setting=s.get("key"), origin=origin):
|
|
self.assertIn(
|
|
"step", s,
|
|
f"{s.get('key')} ({origin}) is type={s.get('type')} with no explicit "
|
|
"step; the parser would default it to 1.0 and snap the slider",
|
|
)
|
|
|
|
def test_step_is_positive(self):
|
|
"""step <= 0 is a hard parse error in noctalia (rejects the whole manifest)."""
|
|
for origin, s in self.settings:
|
|
if "step" in s:
|
|
with self.subTest(setting=s.get("key"), origin=origin):
|
|
self.assertGreater(s["step"], 0, f"{s.get('key')} step must be > 0")
|
|
|
|
def test_step_is_finer_than_range(self):
|
|
"""A step >= the span leaves only the two clamped endpoints reachable."""
|
|
for origin, s in self.settings:
|
|
if "step" in s and "min" in s and "max" in s:
|
|
with self.subTest(setting=s.get("key"), origin=origin):
|
|
span = s["max"] - s["min"]
|
|
self.assertLess(
|
|
s["step"], span,
|
|
f"{s.get('key')} step {s['step']} is not finer than its range {span}",
|
|
)
|
|
|
|
def test_default_lands_on_a_step_boundary(self):
|
|
"""Otherwise the shipped default is a value the slider cannot return to."""
|
|
for origin, s in self.settings:
|
|
if {"step", "min", "default"} <= s.keys() and s.get("type") in NUMERIC:
|
|
with self.subTest(setting=s.get("key"), origin=origin):
|
|
steps = (s["default"] - s["min"]) / s["step"]
|
|
self.assertAlmostEqual(
|
|
steps, round(steps), places=6,
|
|
msg=f"{s.get('key')} default {s['default']} is not an integer number "
|
|
f"of {s['step']} steps from min {s['min']}",
|
|
)
|
|
|
|
def test_default_within_range(self):
|
|
for origin, s in self.settings:
|
|
if {"min", "max", "default"} <= s.keys():
|
|
with self.subTest(setting=s.get("key"), origin=origin):
|
|
self.assertGreaterEqual(s["default"], s["min"])
|
|
self.assertLessEqual(s["default"], s["max"])
|
|
|
|
def test_label_and_description_use_key_form(self):
|
|
"""Raw `label`/`description` are REJECTED by the parser; only *_key works."""
|
|
for origin, s in self.settings:
|
|
with self.subTest(setting=s.get("key"), origin=origin):
|
|
self.assertNotIn("label", s, f"{s.get('key')}: use label_key, not label")
|
|
self.assertNotIn("description", s, f"{s.get('key')}: use description_key")
|
|
self.assertIn("label_key", s, f"{s.get('key')} is missing label_key")
|
|
|
|
|
|
class Translations(unittest.TestCase):
|
|
"""Every *_key must resolve in translations/en.json, or the UI shows a raw key."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
import json
|
|
with open(os.path.join(ROOT, "plugin.toml"), "rb") as fh:
|
|
cls.settings = _settings(tomllib.load(fh))
|
|
with open(os.path.join(ROOT, "translations", "en.json"), encoding="utf-8") as fh:
|
|
cls.en = json.load(fh)
|
|
|
|
def _resolve(self, dotted):
|
|
node = self.en
|
|
for part in dotted.split("."):
|
|
if not isinstance(node, dict) or part not in node:
|
|
return None
|
|
node = node[part]
|
|
return node
|
|
|
|
def test_every_key_resolves(self):
|
|
for origin, s in self.settings:
|
|
for field in ("label_key", "description_key"):
|
|
if field in s:
|
|
with self.subTest(setting=s.get("key"), field=field, origin=origin):
|
|
self.assertIsInstance(
|
|
self._resolve(s[field]), str,
|
|
f"{s[field]} does not resolve to a string in translations/en.json",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|