|
| 1 | +""" |
| 2 | +Engine smoke tests — vertical slice 1. |
| 3 | +
|
| 4 | +Covers the request → response → reactive-binding chain at the integration |
| 5 | +layer using FastAPI's TestClient. Intentionally light on internal unit tests |
| 6 | +of state/dom internals; those follow in slice 2. |
| 7 | +
|
| 8 | +Browser-only branches (the IS_BROWSER guards in state.py, dom.py, client.py) |
| 9 | +are NOT exercised here — they require a Pyodide simulator and are deferred. |
| 10 | +""" |
| 11 | +from dataclasses import dataclass |
| 12 | + |
| 13 | +import pytest |
| 14 | +from fastapi.testclient import TestClient |
| 15 | + |
| 16 | +from violetear import App, Document |
| 17 | + |
| 18 | + |
| 19 | +# Module-level definitions used by the bundle test. |
| 20 | +# The bundle generator (app.py:_generate_bundle) emits user state classes and |
| 21 | +# client functions at module scope, so it pulls their source via inspect.getsource. |
| 22 | +# If the originals are nested inside another function, the source comes back |
| 23 | +# indented and the bundle becomes invalid — see test_bundle_rejects_nested_defs |
| 24 | +# below for the pinned bug. |
| 25 | +@dataclass |
| 26 | +class _BundleState: |
| 27 | + n: int = 0 |
| 28 | + |
| 29 | + |
| 30 | +async def _bundle_click(event): |
| 31 | + pass |
| 32 | + |
| 33 | + |
| 34 | +def test_view_renders_html_doc(): |
| 35 | + """A basic @app.view route returns a fully rendered HTML document.""" |
| 36 | + app = App(title="Smoke Test", version="t1") |
| 37 | + |
| 38 | + @app.view("/") |
| 39 | + def home(): |
| 40 | + doc = Document(title="Hello") |
| 41 | + with doc.body as b: |
| 42 | + b.h1("It works") |
| 43 | + return doc |
| 44 | + |
| 45 | + client = TestClient(app.api) |
| 46 | + r = client.get("/") |
| 47 | + |
| 48 | + assert r.status_code == 200 |
| 49 | + assert "text/html" in r.headers["content-type"] |
| 50 | + assert r.text.lstrip().startswith("<!DOCTYPE html>") |
| 51 | + assert "<title>Hello</title>" in r.text |
| 52 | + assert "<h1>" in r.text |
| 53 | + assert "It works" in r.text |
| 54 | + |
| 55 | + |
| 56 | +def test_reactive_ssr_emits_data_bind_for_text_and_value(): |
| 57 | + """@app.local proxies passed to .text() / .value() emit data-bind-* companions.""" |
| 58 | + app = App(title="Reactive", version="t2") |
| 59 | + |
| 60 | + @app.local |
| 61 | + @dataclass |
| 62 | + class UiState: |
| 63 | + theme: str = "light" |
| 64 | + username: str = "Guest" |
| 65 | + |
| 66 | + @app.view("/") |
| 67 | + def home(): |
| 68 | + doc = Document(title="Reactive") |
| 69 | + with doc.body as b: |
| 70 | + b.span().text(UiState.theme).id("theme-display") |
| 71 | + b.input(type="text").value(UiState.username).id("name-input") |
| 72 | + return doc |
| 73 | + |
| 74 | + client = TestClient(app.api) |
| 75 | + html = client.get("/").text |
| 76 | + |
| 77 | + # Static values rendered for SSR (so first paint is correct before hydration) |
| 78 | + assert "light" in html |
| 79 | + assert 'value="Guest"' in html |
| 80 | + |
| 81 | + # data-bind-* companions emitted for the client runtime to discover |
| 82 | + assert 'data-bind-text="UiState.theme"' in html |
| 83 | + assert 'data-bind-value="UiState.username"' in html |
| 84 | + |
| 85 | + |
| 86 | +def test_rpc_endpoint_validates_body_and_returns_result(): |
| 87 | + """@app.server.rpc exposes the function via POST /_violetear/rpc/<name> with Pydantic body validation.""" |
| 88 | + app = App(title="RPC", version="t3") |
| 89 | + |
| 90 | + @app.server.rpc |
| 91 | + async def add(x: int, y: int) -> int: |
| 92 | + return x + y |
| 93 | + |
| 94 | + @app.view("/") |
| 95 | + def home(): |
| 96 | + return Document(title="RPC") |
| 97 | + |
| 98 | + client = TestClient(app.api) |
| 99 | + |
| 100 | + # Happy path |
| 101 | + r = client.post("/_violetear/rpc/add", json={"x": 2, "y": 3}) |
| 102 | + assert r.status_code == 200, r.text |
| 103 | + assert r.json() == 5 |
| 104 | + |
| 105 | + # Pydantic validation: missing field rejected as 422 |
| 106 | + r = client.post("/_violetear/rpc/add", json={"x": 2}) |
| 107 | + assert r.status_code == 422 |
| 108 | + |
| 109 | + # Pydantic coercion: non-coercible value rejected as 422 |
| 110 | + r = client.post("/_violetear/rpc/add", json={"x": "abc", "y": 1}) |
| 111 | + assert r.status_code == 422 |
| 112 | + |
| 113 | + |
| 114 | +def test_bundle_endpoint_returns_compilable_python(): |
| 115 | + """GET /_violetear/bundle.py returns Pyodide-bound source that is at minimum |
| 116 | + syntactically valid Python — catches regressions in the bundle generator. |
| 117 | +
|
| 118 | + Uses module-level state + callback because the bundle generator expects |
| 119 | + its inputs to be defined at module scope (see test_bundle_rejects_nested_defs).""" |
| 120 | + app = App(title="Bundle", version="t4") |
| 121 | + app.local(_BundleState) |
| 122 | + app.client.callback(_bundle_click) |
| 123 | + |
| 124 | + @app.view("/") |
| 125 | + def home(): |
| 126 | + return Document(title="Bundle") |
| 127 | + |
| 128 | + client = TestClient(app.api) |
| 129 | + r = client.get("/_violetear/bundle.py") |
| 130 | + |
| 131 | + assert r.status_code == 200 |
| 132 | + assert "text/x-python" in r.headers["content-type"] |
| 133 | + |
| 134 | + # The bundle must compile under the host Python — surfaces any |
| 135 | + # syntax-breaking regression in _generate_bundle / dedent layout. |
| 136 | + compile(r.text, "<violetear-bundle>", "exec") |
| 137 | + |
| 138 | + |
| 139 | +@pytest.mark.xfail( |
| 140 | + reason=( |
| 141 | + "Bundle generator (_generate_bundle in app.py:632-634) filters decorator lines " |
| 142 | + "with `c.startswith('@')` instead of `c.strip().startswith('@')`. When a client " |
| 143 | + "function is defined nested inside another function, its source comes back " |
| 144 | + "indented; the decorator line survives the filter and the bundle becomes invalid " |
| 145 | + "Python. Also affects state classes (similar pattern at line 617 uses .strip() " |
| 146 | + "for the decorator but still leaves class body indentation intact). Pinned as a " |
| 147 | + "known limitation: real-world usage defines @app.client.* and @app.local at " |
| 148 | + "module level, where this doesn't bite." |
| 149 | + ), |
| 150 | + strict=True, |
| 151 | +) |
| 152 | +def test_bundle_rejects_nested_defs(): |
| 153 | + """Pin the known limitation: the bundle generator only supports module-level |
| 154 | + state classes and client functions; nested definitions produce invalid bundles.""" |
| 155 | + app = App(title="Nested", version="t6") |
| 156 | + |
| 157 | + @app.local |
| 158 | + @dataclass |
| 159 | + class NestedState: |
| 160 | + n: int = 0 |
| 161 | + |
| 162 | + @app.client.callback |
| 163 | + async def nested_click(event): |
| 164 | + NestedState.n += 1 |
| 165 | + |
| 166 | + @app.view("/") |
| 167 | + def home(): |
| 168 | + return Document(title="x") |
| 169 | + |
| 170 | + client = TestClient(app.api) |
| 171 | + r = client.get("/_violetear/bundle.py") |
| 172 | + compile(r.text, "<violetear-bundle>", "exec") |
| 173 | + |
| 174 | + |
| 175 | +@pytest.mark.xfail( |
| 176 | + reason=( |
| 177 | + "examples/reactivity.py:73 uses class_name= on b.div(...). Element treats " |
| 178 | + "that as a raw attr (rendered as class_name=\"...\" + data-bind-class_name=...) " |
| 179 | + "instead of class=/data-bind-class. Reactive class bindings aren't supported " |
| 180 | + "by markup.py: self._classes lives outside self._attrs, so the proxy-check at " |
| 181 | + "render time never fires for the class= attribute. Pinned as a known gap " |
| 182 | + "rather than silently fixed." |
| 183 | + ), |
| 184 | + strict=True, |
| 185 | +) |
| 186 | +def test_reactive_class_binding_emits_data_bind_class(): |
| 187 | + """Pin the known gap: there is no first-class way to reactively bind the class= attribute.""" |
| 188 | + app = App(title="Class Binding", version="t5") |
| 189 | + |
| 190 | + @app.local |
| 191 | + @dataclass |
| 192 | + class UiState: |
| 193 | + theme: str = "light" |
| 194 | + |
| 195 | + @app.view("/") |
| 196 | + def home(): |
| 197 | + doc = Document(title="x") |
| 198 | + with doc.body as b: |
| 199 | + b.div(class_name=UiState.theme, id="app-container") |
| 200 | + return doc |
| 201 | + |
| 202 | + client = TestClient(app.api) |
| 203 | + html = client.get("/").text |
| 204 | + |
| 205 | + assert 'class="light"' in html |
| 206 | + assert 'data-bind-class="UiState.theme"' in html |
0 commit comments