Skip to content

Commit 1c78dc9

Browse files
apiadclaude
andcommitted
test(engine): slice 2 — state proxy, websocket, pwa, stubs, markup
Fans out coverage in four focused files: - test_state.py — ReactiveProxy / LeafProxy path arithmetic, dict access, setattr mutation, primitive dunders (math/eq/hash/bool), _is_complex classification boundaries. - test_websocket.py — connect/disconnect lifecycle events, realtime message dispatch to @app.server.realtime (deterministic via ack broadcast), reverse-RPC envelope shape, .invoke(client_id) targeting one of two concurrent connections. - test_pwa.py — scope-hashed manifest + sw.js routes, default + custom Manifest behavior, Service-Worker-Allowed header, versioned bundle asset registered in the SW cache list, unknown-scope 404. - test_unit.py — ClientFunctionStub server-side RuntimeError, repr distinguishes callback vs plain, Element.on rejects non-callbacks, has_bindings recursion (data-on-*) and intentional non-coverage of data-bind-* (pinned as the current contract). 32 passed, 2 xfailed (carryover from slice 1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7b64191 commit 1c78dc9

4 files changed

Lines changed: 463 additions & 0 deletions

File tree

tests/test_pwa.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""
2+
Tests for PWA route registration, manifest serving, and Service Worker scope.
3+
"""
4+
import hashlib
5+
import json
6+
7+
from fastapi.testclient import TestClient
8+
9+
from violetear import App, Document, Manifest
10+
11+
12+
def _scope_hash(path: str) -> str:
13+
return hashlib.md5(path.encode()).hexdigest()[:8]
14+
15+
16+
def test_pwa_route_registers_manifest_and_service_worker():
17+
"""@app.view("/foo", pwa=True) exposes manifest + SW under a scope-hashed path."""
18+
app = App(title="PWA App", version="pwa1")
19+
20+
@app.view("/install", pwa=True)
21+
def home():
22+
return Document(title="PWA")
23+
24+
h = _scope_hash("/install")
25+
client = TestClient(app.api)
26+
27+
r = client.get(f"/_violetear/pwa/{h}/manifest.json")
28+
assert r.status_code == 200
29+
assert r.headers["content-type"].startswith("application/json")
30+
31+
manifest = json.loads(r.text)
32+
# Default manifest uses app.title as the name, and backfills scope/start_url
33+
# from the route path.
34+
assert manifest["name"] == "PWA App"
35+
assert manifest["scope"] == "/install"
36+
assert manifest["start_url"] == "/install"
37+
38+
r = client.get(f"/_violetear/pwa/{h}/sw.js")
39+
assert r.status_code == 200
40+
assert r.headers["content-type"].startswith("application/javascript")
41+
# Service-Worker-Allowed is required so a SW served from /_violetear/...
42+
# can control pages at the route's scope.
43+
assert r.headers["service-worker-allowed"] == "/"
44+
45+
46+
def test_pwa_unknown_scope_returns_404():
47+
app = App(title="PWA App", version="pwa2")
48+
49+
@app.view("/")
50+
def home():
51+
return Document(title="x")
52+
53+
client = TestClient(app.api)
54+
55+
assert client.get("/_violetear/pwa/deadbeef/manifest.json").status_code == 404
56+
assert client.get("/_violetear/pwa/deadbeef/sw.js").status_code == 404
57+
58+
59+
def test_pwa_custom_manifest_object_is_honored():
60+
"""Passing a Manifest object overrides defaults but inherits scope from the route."""
61+
app = App(title="App", version="pwa3")
62+
63+
custom = Manifest(
64+
name="Custom Name",
65+
short_name="CN",
66+
theme_color="#6b21a8",
67+
)
68+
69+
@app.view("/app", pwa=custom)
70+
def home():
71+
return Document(title="x")
72+
73+
h = _scope_hash("/app")
74+
client = TestClient(app.api)
75+
r = client.get(f"/_violetear/pwa/{h}/manifest.json")
76+
assert r.status_code == 200
77+
78+
manifest = json.loads(r.text)
79+
assert manifest["name"] == "Custom Name"
80+
assert manifest["short_name"] == "CN"
81+
assert manifest["theme_color"] == "#6b21a8"
82+
# The route registration backfills scope/start_url from the route path
83+
# when the user didn't override them.
84+
assert manifest["scope"] == "/app"
85+
assert manifest["start_url"] == "/app"
86+
87+
88+
def test_pwa_service_worker_caches_versioned_bundle_asset():
89+
"""The generated SW script references the versioned bundle URL."""
90+
app = App(title="App", version="cafef00d")
91+
92+
@app.view("/", pwa=True)
93+
def home():
94+
return Document(title="x")
95+
96+
h = _scope_hash("/")
97+
client = TestClient(app.api)
98+
sw = client.get(f"/_violetear/pwa/{h}/sw.js").text
99+
100+
# The cache name embeds the app version (sanity check on substitution).
101+
assert "violetear-cafef00d" in sw
102+
# The bundle asset is added with the version querystring.
103+
assert "/_violetear/bundle.py?v=cafef00d" in sw

tests/test_state.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""
2+
Unit tests for the reactive state proxy (violetear/state.py).
3+
4+
Server-side branches only — IS_BROWSER paths (ReactiveRegistry.notify) are
5+
skipped here; they need a Pyodide simulator and live in a later slice.
6+
"""
7+
from dataclasses import dataclass, field
8+
9+
from violetear.state import LeafProxy, ReactiveProxy, local
10+
11+
12+
def test_local_returns_reactive_proxy_with_root_path():
13+
@local
14+
@dataclass
15+
class Ui:
16+
theme: str = "light"
17+
18+
assert isinstance(Ui, ReactiveProxy)
19+
assert Ui._path == "Ui"
20+
assert Ui.current_value.theme == "light"
21+
22+
23+
def test_primitive_attribute_returns_leaf_proxy_with_dotted_path():
24+
@local
25+
@dataclass
26+
class Ui:
27+
theme: str = "light"
28+
count: int = 5
29+
30+
theme = Ui.theme
31+
assert isinstance(theme, LeafProxy)
32+
assert theme._path == "Ui.theme"
33+
assert theme.current_value == "light"
34+
35+
count = Ui.count
36+
assert isinstance(count, LeafProxy)
37+
assert count._path == "Ui.count"
38+
assert count.current_value == 5
39+
40+
41+
def test_nested_object_returns_recursive_proxy():
42+
@dataclass
43+
class Inner:
44+
value: str = "x"
45+
46+
@local
47+
@dataclass
48+
class Outer:
49+
inner: Inner = field(default_factory=Inner)
50+
51+
inner_proxy = Outer.inner
52+
assert isinstance(inner_proxy, ReactiveProxy)
53+
assert inner_proxy._path == "Outer.inner"
54+
55+
value_proxy = Outer.inner.value
56+
assert isinstance(value_proxy, LeafProxy)
57+
assert value_proxy._path == "Outer.inner.value"
58+
assert value_proxy.current_value == "x"
59+
60+
61+
def test_dict_access_via_getitem_builds_path():
62+
@local
63+
@dataclass
64+
class Store:
65+
users: dict = field(default_factory=lambda: {"alice": "admin"})
66+
67+
role = Store.users["alice"]
68+
assert isinstance(role, LeafProxy)
69+
assert role._path == "Store.users.alice"
70+
assert role.current_value == "admin"
71+
72+
73+
def test_setattr_mutates_underlying_target():
74+
@local
75+
@dataclass
76+
class Ui:
77+
theme: str = "light"
78+
79+
Ui.theme = "dark"
80+
81+
assert Ui.current_value.theme == "dark"
82+
assert Ui.theme.current_value == "dark"
83+
84+
85+
def test_leaf_proxy_arithmetic_dunders():
86+
proxy = LeafProxy(5, "x.n")
87+
88+
assert proxy + 1 == 6
89+
assert 1 + proxy == 6
90+
assert proxy - 2 == 3
91+
assert proxy * 3 == 15
92+
assert str(proxy) == "5"
93+
assert int(proxy) == 5
94+
assert float(proxy) == 5.0
95+
assert bool(proxy) is True
96+
97+
zero = LeafProxy(0, "x.n")
98+
assert bool(zero) is False
99+
100+
101+
def test_leaf_proxy_equality_and_hash():
102+
a = LeafProxy("light", "x.theme")
103+
b = LeafProxy("light", "y.theme") # different path, same value
104+
105+
assert a == b
106+
assert a == "light"
107+
assert a != "dark"
108+
assert hash(a) == hash("light")
109+
110+
111+
def test_is_complex_classification():
112+
@dataclass
113+
class Obj:
114+
x: int = 1
115+
116+
proxy = ReactiveProxy(Obj(), "test")
117+
118+
assert proxy._is_complex({"a": 1}) is True
119+
assert proxy._is_complex([1, 2, 3]) is True
120+
assert proxy._is_complex(Obj()) is True
121+
assert proxy._is_complex("string") is False
122+
assert proxy._is_complex(42) is False
123+
assert proxy._is_complex(True) is False

tests/test_unit.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""
2+
Unit-level tests for ClientFunctionStub safety semantics and Element helpers.
3+
"""
4+
import pytest
5+
6+
from violetear import App
7+
from violetear.markup import Element
8+
9+
10+
def test_client_stub_raises_when_called_from_server():
11+
"""The stub returned by @app.client.* must refuse to execute server-side."""
12+
app = App(version="u1")
13+
14+
@app.client.callback
15+
async def on_click(event):
16+
pass
17+
18+
with pytest.raises(RuntimeError, match="cannot be called from the Server"):
19+
on_click(None)
20+
21+
22+
def test_client_callback_stub_repr_distinguishes_kind():
23+
app = App(version="u2")
24+
25+
@app.client.callback
26+
async def cb(event):
27+
pass
28+
29+
@app.client
30+
async def plain():
31+
pass
32+
33+
# Repr should distinguish a DOM-binding callback from a plain client function.
34+
assert "callback" in repr(cb)
35+
assert "callback" not in repr(plain)
36+
assert "client:" in repr(plain)
37+
38+
39+
def test_element_on_rejects_non_callback_handler():
40+
"""Element.on requires the handler to carry the @app.client.callback marker."""
41+
app = App(version="u3") # noqa: F841 — instantiated to keep the test honest
42+
43+
async def not_a_callback(event):
44+
pass
45+
46+
el = Element("button")
47+
with pytest.raises(ValueError, match="@app.client.callback"):
48+
el.on("click", not_a_callback)
49+
50+
51+
def test_element_has_bindings_recurses_through_children():
52+
"""has_bindings finds data-on-* on self or any descendant."""
53+
app = App(version="u4")
54+
55+
@app.client.callback
56+
async def cb(event):
57+
pass
58+
59+
parent = Element("div")
60+
child = Element("button").on("click", cb)
61+
grandchild = Element("span")
62+
63+
child.add(grandchild)
64+
parent.add(child)
65+
66+
assert parent.has_bindings() is True
67+
assert child.has_bindings() is True
68+
assert grandchild.has_bindings() is False
69+
70+
71+
def test_element_has_bindings_ignores_data_bind_attrs():
72+
"""has_bindings counts only data-on-* (event handlers), not data-bind-* (reactive)."""
73+
el = Element("span")
74+
el._attrs["data-bind-text"] = "Ui.theme"
75+
76+
# Pinned: reactive-only elements report no bindings. This is the current
77+
# contract; if has_bindings is ever made the gate for Pyodide injection,
78+
# this assertion should flip.
79+
assert el.has_bindings() is False

0 commit comments

Comments
 (0)