diff --git a/.claude/commands/unit-tester.md b/.claude/commands/unit-tester.md index 93a26ec..5e9357e 100644 --- a/.claude/commands/unit-tester.md +++ b/.claude/commands/unit-tester.md @@ -201,190 +201,283 @@ class TestControlRender: ### UTR-11: Required Reading for Control Render Tests -**Before writing ANY render tests for Controls, you MUST:** +--- -1. **Read the matcher documentation**: `docs/testing_rendered_components.md` -2. **Understand the key concepts**: - - How `matches()` and `find()` work - - When to use predicates (Contains, StartsWith, AnyValue, etc.) - - How to test only what matters (not every detail) - - How to read error messages with `^^^` markers -3. **Apply the best practices** detailed below +#### **UTR-11.0: Read the matcher documentation (MANDATORY PREREQUISITE)** + +**Principle:** Before writing any render tests, you MUST read and understand the complete matcher documentation. + +**Mandatory reading:** `docs/testing_rendered_components.md` + +**What you must master:** +- **`matches(actual, expected)`** - How to validate that an element matches your expectations +- **`find(ft, expected)`** - How to search for elements within an HTML tree +- **Predicates** - How to test patterns instead of exact values: + - `Contains()`, `StartsWith()`, `DoesNotContain()`, `AnyValue()` for attributes + - `Empty()`, `NoChildren()`, `AttributeForbidden()` for children +- **Error messages** - How to read `^^^` markers to understand differences +- **Key principle** - Test only what matters, ignore the rest + +**Without this reading, you cannot write correct render tests.** --- -#### **UTR-11.1 : Pattern de test en trois étapes (RÈGLE FONDAMENTALE)** +### **TEST FILE STRUCTURE** -**Principe :** C'est le pattern par défaut à appliquer pour tous les tests de rendu. Les autres règles sont des compléments à ce pattern. +--- -**Les trois étapes :** -1. **Extraire l'élément à tester** avec `find_one()` ou `find()` à partir du rendu global -2. **Définir la structure attendue** avec `expected = ...` -3. **Comparer** avec `assert matches(element, expected)` +#### **UTR-11.1: Always start with a global structure test (FUNDAMENTAL RULE)** -**Pourquoi :** Ce pattern permet des messages d'erreur clairs et sépare la recherche de l'élément de la validation de sa structure. +**Principle:** The **first render test** must ALWAYS verify the global HTML structure of the component. This is the test that helps readers understand the general architecture. -**Exemple :** +**Why:** +- Gives immediate overview of the structure +- Facilitates understanding for new contributors +- Quickly detects major structural changes +- Serves as living documentation of HTML architecture + +**Test format:** +```python +def test_i_can_render_component_with_no_data(self, component): + """Test that Component renders with correct global structure.""" + html = component.render() + expected = Div( + Div(id=f"{component.get_id()}-controller"), # controller + Div(id=f"{component.get_id()}-header"), # header + Div(id=f"{component.get_id()}-content"), # content + id=component.get_id(), + ) + assert matches(html, expected) +``` + +**Notes:** +- Simple test with only IDs of main sections +- Inline comments to identify each section +- No detailed verification of attributes (classes, content, etc.) +- This test must be the first in the `TestComponentRender` class + +**Test order:** +1. **First test:** Global structure (UTR-11.1) +2. **Following tests:** Details of each section (UTR-11.2 to UTR-11.10) + +--- + +#### **UTR-11.2: Break down complex tests into explicit steps** + +**Principle:** When a test verifies multiple levels of HTML nesting, break it down into numbered steps with explicit comments. + +**Why:** +- Facilitates debugging (you know exactly which step fails) +- Improves test readability +- Allows validating structure level by level + +**Example:** +```python +def test_content_wrapper_when_tab_active(self, tabs_manager): + """Test that content wrapper shows active tab content.""" + tab_id = tabs_manager.create_tab("Tab1", Div("My Content")) + wrapper = tabs_manager._mk_tab_content_wrapper() + + # Step 1: Validate wrapper global structure + expected = Div( + Div(), # tab content, tested in step 2 + id=f"{tabs_manager.get_id()}-content-wrapper", + cls=Contains("mf-tab-content-wrapper"), + ) + assert matches(wrapper, expected) + + # Step 2: Extract and validate specific content + tab_content = find_one(wrapper, Div(id=f"{tabs_manager.get_id()}-{tab_id}-content")) + expected = Div( + Div("My Content"), # <= actual content + cls=Contains("mf-tab-content"), + ) + assert matches(tab_content, expected) +``` + +**Pattern:** +- Step 1: Global structure with empty `Div()` + comment for children tested after +- Step 2+: Extraction with `find_one()` + detailed validation + +--- + +#### **UTR-11.3: Three-step pattern for simple tests** + +**Principle:** For tests not requiring multi-level decomposition, use the standard three-step pattern. + +**The three steps:** +1. **Extract the element to test** with `find_one()` or `find()` from the global render +2. **Define the expected structure** with `expected = ...` +3. **Compare** with `assert matches(element, expected)` + +**Example:** ```python -# ✅ BON - Pattern en trois étapes def test_header_has_two_sides(self, layout): """Test that there is a left and right header section.""" - # Étape 1 : Extraire l'élément à tester + # Step 1: Extract the element to test header = find_one(layout.render(), Header(cls=Contains("mf-layout-header"))) - # Étape 2 : Définir la structure attendue + # Step 2: Define the expected structure expected = Header( Div(id=f"{layout._id}_hl"), Div(id=f"{layout._id}_hr"), ) - # Étape 3 : Comparer + # Step 3: Compare assert matches(header, expected) - -# ❌ À ÉVITER - Tout imbriqué en une ligne -def test_header_has_two_sides(self, layout): - assert matches( - find_one(layout.render(), Header(cls=Contains("mf-layout-header"))), - Header(Div(id=f"{layout._id}_hl"), Div(id=f"{layout._id}_hr")) - ) ``` -**Note :** Cette règle s'applique à presque tous les tests. Les autres règles ci-dessous complètent ce pattern fondamental. +--- + +### **HOW TO SEARCH FOR ELEMENTS** --- -#### **COMMENT CHERCHER LES ÉLÉMENTS** +#### **UTR-11.4: Prefer searching by ID** ---- +**Principle:** Always search for an element by its `id` when it has one, rather than by class or other attribute. -#### **UTR-11.2 : Privilégier la recherche par ID** +**Why:** More robust, faster, and targeted (an ID is unique). -**Principe :** Toujours chercher un élément par son `id` quand il en a un, plutôt que par classe ou autre attribut. - -**Pourquoi :** Plus robuste, plus rapide, et ciblé (un ID est unique). - -**Exemple :** +**Example:** ```python -# ✅ BON - recherche par ID +# ✅ GOOD - search by ID drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld")) -# ❌ À ÉVITER - recherche par classe quand un ID existe +# ❌ AVOID - search by class when an ID exists drawer = find_one(layout.render(), Div(cls=Contains("mf-layout-left-drawer"))) ``` --- -#### **UTR-11.3 : Utiliser `find_one()` vs `find()` selon le contexte** +#### **UTR-11.5: Use `find_one()` vs `find()` based on context** -**Principe :** -- `find_one()` : Quand vous cherchez un élément unique et voulez tester sa structure complète -- `find()` : Quand vous cherchez plusieurs éléments ou voulez compter/vérifier leur présence +**Principle:** +- `find_one()`: When you search for a unique element and want to test its complete structure +- `find()`: When you search for multiple elements or want to count/verify their presence -**Exemples :** +**Examples:** ```python -# ✅ BON - find_one pour structure unique +# ✅ GOOD - find_one for unique structure header = find_one(layout.render(), Header(cls=Contains("mf-layout-header"))) expected = Header(...) assert matches(header, expected) -# ✅ BON - find pour compter +# ✅ GOOD - find for counting resizers = find(drawer, Div(cls=Contains("mf-resizer-left"))) assert len(resizers) == 1, "Left drawer should contain exactly one resizer element" ``` --- -#### **COMMENT SPÉCIFIER LA STRUCTURE ATTENDUE** +### **HOW TO SPECIFY EXPECTED STRUCTURE** --- -#### **UTR-11.4 : Toujours utiliser `Contains()` pour les attributs `cls` et `style`** +#### **UTR-11.6: Always use `Contains()` for `cls` and `style` attributes** -**Principe :** -- Pour `cls` : Les classes CSS peuvent être dans n'importe quel ordre. Testez uniquement les classes importantes avec `Contains()`. -- Pour `style` : Les propriétés CSS peuvent être dans n'importe quel ordre. Testez uniquement les propriétés importantes avec `Contains()`. +**Principle:** +- For `cls`: CSS classes can be in any order. Test only important classes with `Contains()`. +- For `style`: CSS properties can be in any order. Test only important properties with `Contains()`. -**Pourquoi :** Évite les faux négatifs dus à l'ordre des classes/propriétés ou aux espaces. +**Why:** Avoids false negatives due to class/property order or spacing. -**Exemples :** +**Examples:** ```python -# ✅ BON - Contains pour cls (une ou plusieurs classes) +# ✅ GOOD - Contains for cls (one or more classes) expected = Div(cls=Contains("mf-layout-drawer")) expected = Div(cls=Contains("mf-layout-drawer", "mf-layout-left-drawer")) -# ✅ BON - Contains pour style +# ✅ GOOD - Contains for style expected = Div(style=Contains("width: 250px")) -# ❌ À ÉVITER - test exact des classes +# ❌ AVOID - exact class test expected = Div(cls="mf-layout-drawer mf-layout-left-drawer") -# ❌ À ÉVITER - test exact du style complet +# ❌ AVOID - exact complete style test expected = Div(style="width: 250px; overflow: hidden; display: flex;") ``` --- -#### **UTR-11.5 : Utiliser `TestIcon()` pour tester la présence d'une icône** +#### **UTR-11.7: Use `TestIcon()` or `TestIconNotStr()` to test icon presence** -**Principe :** Utilisez `TestIcon("icon_name")` pour tester la présence d'une icône SVG dans le rendu. +**Principle:** Use `TestIcon()` or `TestIconNotStr()` depending on how the icon is integrated in the code. -**Le paramètre `name` :** -- **Nom exact** : Utilisez le nom exact de l'import (ex: `TestIcon("panel_right_expand20_regular")`) pour valider une icône spécifique -- **`name=""`** (chaîne vide) : Valide **n'importe quelle icône**. Le test sera passant dès que la structure affichant une icône sera trouvée, peu importe laquelle. -- **JAMAIS `name="svg"`** : Cela causera des échecs de test +**Difference between the two:** +- **`TestIcon("icon_name")`**: Searches for the pattern `
test
\n' + +You can create new FTs by importing the new component from +`fasthtml.components`. If the FT doesn’t exist within that module, +FastHTML will create it. + +``` python +from fasthtml.components import Some_never_before_used_tag + +Some_never_before_used_tag() +``` + +``` html +This is a hero statement
\nLet's do this!
\n' + +## JS + +The [`Script`](https://www.fastht.ml/docs/api/xtend.html#script) +function allows you to include JavaScript. You can use Python to +generate parts of your JS or JSON like this: + +``` python +# In future snippets this import will not be shown, but is required +from fasthtml.common import * +app,rt = fast_app(hdrs=[Script(src="https://cdn.plot.ly/plotly-2.32.0.min.js")]) +# `index` is a special function name which maps to the `/` route. +@rt +def index(): + data = {'somedata':'fill me in…'} + # `Titled` returns a title tag and an h1 tag with the 1st param, with remaining params as children in a `Main` parent. + return Titled("Chart Demo", Div(id="myDiv"), Script(f"var data = {data}; Plotly.newPlot('myDiv', data);")) +# In future snippets `serve() will not be shown, but is required +serve() +``` + +Prefer Python whenever possible over JS. Never use React or shadcn. + +## fast_app hdrs + +``` python +# In future snippets we'll skip showing the `fast_app` call if it has no params +app, rt = fast_app( + pico=False, # The Pico CSS framework is included by default, so pass `False` to disable it if needed. No other CSS frameworks are included. + # These are added to the `head` part of the page for non-HTMX requests. + hdrs=( + Link(rel='stylesheet', href='assets/normalize.min.css', type='text/css'), + Link(rel='stylesheet', href='assets/sakura.css', type='text/css'), + Style("p {color: red;}"), + # `MarkdownJS` and `HighlightJS` are available via concise functions + MarkdownJS(), HighlightJS(langs=['python', 'javascript', 'html', 'css']), + # by default, all standard static extensions are served statically from the web app dir, + # which can be modified using e.g `static_path='public'` + ) +) + +@rt +def index(req): return Titled("Markdown rendering example", + # This will be client-side rendered to HTML with highlight-js + Div("*hi* there",cls="marked"), + # This will be syntax highlighted + Pre(Code("def foo(): pass"))) +``` + +## Responses + +Routes can return various types: + +1. FastTags or tuples of FastTags (automatically rendered to HTML) +2. Standard Starlette responses (used directly) +3. JSON-serializable types (returned as JSON in a plain text response) + +``` python +@rt("/{fname:path}.{ext:static}") +async def serve_static_file(fname:str, ext:str): return FileResponse(f'public/{fname}.{ext}') + +app, rt = fast_app(hdrs=(MarkdownJS(), HighlightJS(langs=['python', 'javascript']))) +@rt +def index(): + return Titled("Example", + Div("*markdown* here", cls="marked"), + Pre(Code("def foo(): pass"))) +``` + +Route functions can be used in attributes like `href` or `action` and +will be converted to paths. Use `.to()` to generate paths with query +parameters. + +``` python +@rt +def profile(email:str): return fill_form(profile_form, profiles[email]) + +profile_form = Form(action=profile)( + Label("Email", Input(name="email")), + Button("Save", type="submit") +) + +user_profile_path = profile.to(email="user@example.com") # '/profile?email=user%40example.com' +``` + +``` python +from dataclasses import dataclass + +app,rt = fast_app() +``` + +When a route handler function is used as a fasttag attribute (such as +`href`, `hx_get`, or `action`) it is converted to that route’s path. +[`fill_form`](https://www.fastht.ml/docs/api/components.html#fill_form) +is used to copy an object’s matching attrs into matching-name form +fields. + +``` python +@dataclass +class Profile: email:str; phone:str; age:int +email = 'john@example.com' +profiles = {email: Profile(email=email, phone='123456789', age=5)} +@rt +def profile(email:str): return fill_form(profile_form, profiles[email]) + +profile_form = Form(method="post", action=profile)( + Fieldset( + Label('Email', Input(name="email")), + Label("Phone", Input(name="phone")), + Label("Age", Input(name="age"))), + Button("Save", type="submit")) +``` + +## Testing + +We can use `TestClient` for testing. + +``` python +from starlette.testclient import TestClient +``` + +``` python +path = "/profile?email=john@example.com" +client = TestClient(app) +htmx_req = {'HX-Request':'1'} +print(client.get(path, headers=htmx_req).text) +``` + + + +## Form Handling and Data Binding + +When a dataclass, namedtuple, etc. is used as a type annotation, the +form body will be unpacked into matching attribute names automatically. + +``` python +@rt +def edit_profile(profile: Profile): + profiles[email]=profile + return RedirectResponse(url=path) + +new_data = dict(email='john@example.com', phone='7654321', age=25) +print(client.post("/edit_profile", data=new_data, headers=htmx_req).text) +``` + + + +## fasttag Rendering Rules + +The general rules for rendering children inside tuples or fasttag +children are: - `__ft__` method will be called (for default components +like `P`, `H2`, etc. or if you define your own components) - If you pass +a string, it will be escaped - On other python objects, `str()` will be +called + +If you want to include plain HTML tags directly into e.g. a `Div()` they +will get escaped by default (as a security measure to avoid code +injections). This can be avoided by using `Safe(...)`, e.g to show a +data frame use `Div(NotStr(df.to_html()))`. + +## Exceptions + +FastHTML allows customization of exception handlers. + +``` python +def not_found(req, exc): return Titled("404: I don't exist!") +exception_handlers = {404: not_found} +app, rt = fast_app(exception_handlers=exception_handlers) +``` + +## Cookies + +We can set cookies using the +[`cookie()`](https://www.fastht.ml/docs/api/core.html#cookie) function. + +``` python +@rt +def setcook(): return P(f'Set'), cookie('mycookie', 'foobar') +print(client.get('/setcook', headers=htmx_req).text) +``` + +Set
+ +``` python +@rt +def getcook(mycookie:str): return f'Got {mycookie}' +# If handlers return text instead of FTs, then a plaintext response is automatically created +print(client.get('/getcook').text) +``` + + Got foobar + +FastHTML provide access to Starlette’s request object automatically +using special `request` parameter name (or any prefix of that name). + +``` python +@rt +def headers(req): return req.headers['host'] +``` + +## Request and Session Objects + +FastHTML provides access to Starlette’s session middleware automatically +using the special `session` parameter name (or any prefix of that name). + +``` python +@rt +def profile(req, sess, user_id: int=None): + ip = req.client.host + sess['last_visit'] = datetime.now().isoformat() + visits = sess.setdefault('visit_count', 0) + 1 + sess['visit_count'] = visits + user = get_user(user_id or sess.get('user_id')) + return Titled(f"Profile: {user.name}", + P(f"Visits: {visits}"), + P(f"IP: {ip}"), + Button("Logout", hx_post=logout)) +``` + +Handler functions can return the +[`HtmxResponseHeaders`](https://www.fastht.ml/docs/api/core.html#htmxresponseheaders) +object to set HTMX-specific response headers. + +``` python +@rt +def htmlredirect(app): return HtmxResponseHeaders(location="http://example.org") +``` + +## APIRouter + +[`APIRouter`](https://www.fastht.ml/docs/api/core.html#apirouter) lets +you organize routes across multiple files in a FastHTML app. + +``` python +# products.py +ar = APIRouter() + +@ar +def details(pid: int): return f"Here are the product details for ID: {pid}" + +@ar +def all_products(req): + return Div( + Div( + Button("Details",hx_get=details.to(pid=42),hx_target="#products_list",hx_swap="outerHTML",), + ), id="products_list") +``` + +``` python +# main.py +from products import ar,all_products + +app, rt = fast_app() +ar.to_app(app) + +@rt +def index(): + return Div( + "Products", + hx_get=all_products, hx_swap="outerHTML") +``` + +## Toasts + +Toasts can be of four types: + +- info +- success +- warning +- error + +Toasts require the use of the `setup_toasts()` function, plus every +handler needs: + +- The session argument +- Must return FT components + +``` python +setup_toasts(app) + +@rt +def toasting(session): + add_toast(session, f"cooked", "info") + add_toast(session, f"ready", "success") + return Titled("toaster") +``` + +`setup_toasts(duration)` allows you to specify how long a toast will be +visible before disappearing.10 seconds. + +Authentication and authorization are handled with Beforeware, which +functions that run before the route handler is called. + +## Auth + +``` python +def user_auth_before(req, sess): + # `auth` key in the request scope is automatically provided to any handler which requests it and can not be injected + auth = req.scope['auth'] = sess.get('auth', None) + if not auth: return RedirectResponse('/login', status_code=303) + +beforeware = Beforeware( + user_auth_before, + skip=[r'/favicon\.ico', r'/static/.*', r'.*\.css', r'.*\.js', '/login', '/'] +) + +app, rt = fast_app(before=beforeware) +``` + +## Server-Side Events (SSE) + +FastHTML supports the HTMX SSE extension. + +``` python +import random +hdrs=(Script(src="https://unpkg.com/htmx-ext-sse@2.2.3/sse.js"),) +app,rt = fast_app(hdrs=hdrs) + +@rt +def index(): return Div(hx_ext="sse", sse_connect="/numstream", hx_swap="beforeend show:bottom", sse_swap="message") + +# `signal_shutdown()` gets an event that is set on shutdown +shutdown_event = signal_shutdown() + +async def number_generator(): + while not shutdown_event.is_set(): + data = Article(random.randint(1, 100)) + yield sse_message(data) + +@rt +async def numstream(): return EventStream(number_generator()) +``` + +## Websockets + +FastHTML provides useful tools for HTMX’s websockets extension. + +``` python +# These HTMX extensions are available through `exts`: +# head-support preload class-tools loading-states multi-swap path-deps remove-me ws chunked-transfer +app, rt = fast_app(exts='ws') + +def mk_inp(): return Input(id='msg', autofocus=True) + +@rt +async def index(request): + # `ws_send` tells HTMX to send a message to the nearest websocket based on the trigger for the form element + cts = Div( + Div(id='notifications'), + Form(mk_inp(), id='form', ws_send=True), + hx_ext='ws', ws_connect='/ws') + return Titled('Websocket Test', cts) + +async def on_connect(send): await send(Div('Hello, you have connected', id="notifications")) +async def on_disconnect(ws): print('Disconnected!') + +@app.ws('/ws', conn=on_connect, disconn=on_disconnect) +async def ws(msg:str, send): + # websocket hander returns/sends are treated as OOB swaps + await send(Div('Hello ' + msg, id="notifications")) + return Div('Goodbye ' + msg, id="notifications"), mk_inp() +``` + +Sample chatbot that uses FastHTML’s `setup_ws` function: + +``` py +app = FastHTML(exts='ws') +rt = app.route +msgs = [] + +@rt('/') +def home(): + return Div(hx_ext='ws', ws_connect='/ws')( + Div(Ul(*[Li(m) for m in msgs], id='msg-list')), + Form(Input(id='msg'), id='form', ws_send=True) + ) + +async def ws(msg:str): + msgs.append(msg) + await send(Ul(*[Li(m) for m in msgs], id='msg-list')) + +send = setup_ws(app, ws) +``` + +### Single File Uploads + +[`Form`](https://www.fastht.ml/docs/api/xtend.html#form) defaults to +“multipart/form-data”. A Starlette UploadFile is passed to the handler. + +``` python +upload_dir = Path("filez") + +@rt +def index(): + return ( + Form(hx_post=upload, hx_target="#result")( + Input(type="file", name="file"), + Button("Upload", type="submit")), + Div(id="result") + ) + +# Use `async` handlers where IO is used to avoid blocking other clients +@rt +async def upload(file: UploadFile): + filebuffer = await file.read() + (upload_dir / file.filename).write_bytes(filebuffer) + return P('Size: ', file.size) +``` + +For multi-file, use `Input(..., multiple=True)`, and a type annotation +of `list[UploadFile]` in the handler. + +## Fastlite + +Fastlite and the MiniDataAPI specification it’s built on are a +CRUD-oriented API for working with SQLite. APSW and apswutils is used to +connect to SQLite, optimized for speed and clean error handling. + +``` python +from fastlite import * +``` + +``` python +db = database(':memory:') # or database('data/app.db') +``` + +Tables are normally constructed with classes, field types are specified +as type hints. + +``` python +class Book: isbn: str; title: str; pages: int; userid: int +# The transform arg instructs fastlite to change the db schema when fields change. +# Create only creates a table if the table doesn't exist. +books = db.create(Book, pk='isbn', transform=True) + +class User: id: int; name: str; active: bool = True +# If no pk is provided, id is used as the primary key. +users = db.create(User, transform=True) +users +``` + +