Files
LLeMbas/tests/test_sidebar_split.py
T
Jaroslav Beneš 20040f53a8 Three things that said one thing and did another
All three shipped in the last two commits, and all three are the same kind of
mistake: an interface that looks right and is not.

The folder settings page could not be scrolled. `.main` is a flex column with
`min-height: 0`, so a `.page` dropped straight into it overflows the viewport
with nothing to scroll -- Save and Back end up below the bottom of the window,
reachable by zooming out or by dragging the prompt textarea up out of the way.
Every other page of this shape already wraps its content in `.admin-scroll`;
this one did not. The two class names that scroll are one rule in admin.css
precisely so this is a wrapper somebody forgot rather than a value they got
wrong, and now it is noted.

The project directory was a text box, on the one screen that asks for an
absolute path on another machine. It is the same button-and-hidden-field the
new-chat screen uses, wired by `[data-dir-field]` in ui.js -- scoped to that
attribute so this and the composer's own handler cannot both answer one click
and open two dialogs. The composer keeps its own because it does more: it
follows the selected profile's default directory until somebody picks their
own, which only means something while a chat is being created. With no
connection chosen it says so rather than opening onto nothing, and Clear is
always there, because browsing somewhere and changing your mind before saving
needs a way back to "no opinion" as much as clearing a saved one does.

And "New chat" did not follow the Chat/Agent switch. The button sits above the
scroll area rather than inside the tree the switch swaps, so it went on saying
"New chat" over a list of agent chats. It moves to its own partial and arrives
out of band, the way the chat title already does. Renaming it to something
neutral would have hidden the bug rather than fixed it, and would have cost the
`?kind=agent` preselection the label is there to explain.

The tests that existed asserted a page load, which re-renders the button
anyway -- which is exactly why nobody saw it. The new ones assert the fragment.
The directory field was driven under a DOM stub first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:40:01 +02:00

251 lines
9.6 KiB
Python

"""The sidebar's Chat/Agent switch: what it stores, and what it hides."""
from __future__ import annotations
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import Chat, Connection, Folder, Model, User
from lembas.services import settings_store
from lembas.services.crypto import encrypt
def _enable_agents(db) -> None:
"""The split only applies when agent chats are possible.
With them off the sidebar deliberately goes back to showing everything, so
a test that did not do this would be asserting against the un-split
behaviour and passing for the wrong reason.
"""
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
def _add_connection(db) -> Connection:
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="test-model"))
db.commit()
return connection
def _chat(db, *, title: str, kind: str = "chat", folder: Folder | None = None) -> Chat:
user = db.scalars(select(User).order_by(User.created_at)).first()
chat = Chat(
user_id=user.id,
title=title,
kind=kind,
model_id="test-model",
folder_id=folder.id if folder else None,
)
db.add(chat)
db.commit()
return chat
def _folder(db, name: str, parent: Folder | None = None) -> Folder:
user = db.scalars(select(User).order_by(User.created_at)).first()
folder = Folder(user_id=user.id, name=name, parent_id=parent.id if parent else None)
db.add(folder)
db.commit()
return folder
# --- What the switch stores ---------------------------------------------------
def test_the_switch_stores_the_choice_and_returns_the_tree(client: TestClient, db, registered):
response = client.post("/api/preferences/sidebar-kind", data={"kind": "agent"})
assert response.status_code == 200
# The fragment, not a redirect and not a full page: the folder open/closed
# state must survive a flick of the switch.
assert 'id="sidebar-tree"' in response.text
assert "<!doctype html>" not in response.text.lower()
user = db.scalars(select(User).order_by(User.created_at)).first()
db.refresh(user)
assert user.settings_json["sidebar_kind"] == "agent"
def test_an_unknown_kind_is_refused_rather_than_stored(client: TestClient, db, registered):
"""`sidebar_kind` reads anything unrecognised back as "chat", so storing it
would be a preference that silently does nothing."""
assert client.post("/api/preferences/sidebar-kind", data={"kind": "wizard"}).status_code == 400
user = db.scalars(select(User).order_by(User.created_at)).first()
db.refresh(user)
assert "sidebar_kind" not in (user.settings_json or {})
def test_the_new_chat_button_follows_the_switch(client: TestClient, db, registered):
"""It sits above the scroll area rather than inside the tree, so a swap of
the tree alone left it saying "New chat" while agent chats were listed
underneath -- saying one thing and doing another, which is the shape of
failure the switch itself was arranged to avoid.
Asserted on the *fragment*, not on a page load: a page load re-renders the
button anyway, which is exactly why this went unnoticed.
"""
_add_connection(db)
_enable_agents(db)
response = client.post("/api/preferences/sidebar-kind", data={"kind": "agent"})
assert 'id="sidebar-actions"' in response.text
assert 'hx-swap-oob="true"' in response.text
assert "New agent chat" in response.text
assert 'href="/chat?kind=agent"' in response.text
response = client.post("/api/preferences/sidebar-kind", data={"kind": "chat"})
assert "New agent chat" not in response.text
assert 'href="/chat"' in response.text
def test_a_page_load_carries_no_stray_out_of_band_row(
client: TestClient, db, registered
):
"""`hx-swap-oob` on a full page load would be a duplicate element sitting in
the tree, waiting to be swapped by the next unrelated request."""
_add_connection(db)
_enable_agents(db)
page = client.get("/chat").text
assert page.count('id="sidebar-actions"') == 1
assert 'id="sidebar-actions"\n hx-swap-oob' not in page
assert "hx-swap-oob" not in page.split('id="sidebar-tree"')[1][:2000]
def test_the_switch_survives_a_page_load(client: TestClient, db, registered):
_add_connection(db)
_enable_agents(db)
client.post("/api/preferences/sidebar-kind", data={"kind": "agent"})
page = client.get("/chat").text
assert 'id="sidebar-kind-agent"' in page
# The Agent side is what "New chat" opens on, so the fork is already picked.
assert 'href="/chat?kind=agent"' in page
# --- What each side shows ------------------------------------------------------
def test_each_side_shows_only_its_own_kind(client: TestClient, db, registered):
_add_connection(db)
_enable_agents(db)
_chat(db, title="An ordinary question")
_chat(db, title="A machine errand", kind="agent")
page = client.get("/chat").text
assert "An ordinary question" in page
assert "A machine errand" not in page
client.post("/api/preferences/sidebar-kind", data={"kind": "agent"})
page = client.get("/chat").text
assert "A machine errand" in page
assert "An ordinary question" not in page
def test_a_folder_of_the_other_kind_is_hidden(client: TestClient, db, registered):
_add_connection(db)
_enable_agents(db)
folder = _folder(db, "Errands")
_chat(db, title="A machine errand", kind="agent", folder=folder)
# Chat side: the folder holds something, but nothing of this kind.
assert "Errands" not in client.get("/chat").text
client.post("/api/preferences/sidebar-kind", data={"kind": "agent"})
page = client.get("/chat").text
assert "Errands" in page
assert "A machine errand" in page
def test_a_folder_matching_three_levels_down_is_shown(client: TestClient, db, registered):
"""Judging a folder on its own contents alone would bury it."""
_add_connection(db)
_enable_agents(db)
top = _folder(db, "Top")
middle = _folder(db, "Middle", parent=top)
bottom = _folder(db, "Bottom", parent=middle)
_chat(db, title="A machine errand", kind="agent", folder=bottom)
client.post("/api/preferences/sidebar-kind", data={"kind": "agent"})
page = client.get("/chat").text
assert "Top" in page
assert "Middle" in page
assert "Bottom" in page
assert "A machine errand" in page
def test_an_empty_folder_shows_on_both_sides(client: TestClient, db, registered):
"""Two reasons a folder can look empty, and only one is a reason to hide it.
A folder the filter emptied is noise. A folder that was empty to begin with
is a container somebody just made -- hiding that one means it can never be
found again, let alone filed into.
"""
_add_connection(db)
_enable_agents(db)
_folder(db, "Waiting")
assert "Waiting" in client.get("/chat").text
client.post("/api/preferences/sidebar-kind", data={"kind": "agent"})
assert "Waiting" in client.get("/chat").text
def test_a_folder_holding_both_kinds_shows_the_right_half(client: TestClient, db, registered):
_add_connection(db)
_enable_agents(db)
folder = _folder(db, "Mixed")
_chat(db, title="An ordinary question", folder=folder)
_chat(db, title="A machine errand", kind="agent", folder=folder)
page = client.get("/chat").text
assert "Mixed" in page
assert "An ordinary question" in page
assert "A machine errand" not in page
client.post("/api/preferences/sidebar-kind", data={"kind": "agent"})
page = client.get("/chat").text
assert "Mixed" in page
assert "A machine errand" in page
assert "An ordinary question" not in page
# --- Whether the switch appears at all -----------------------------------------
def test_the_switch_is_absent_when_agent_chats_are_off(client: TestClient, db, registered):
"""A two-way switch with one useful side is worse than no switch: it offers
a view that is empty by construction and cannot be made otherwise."""
_add_connection(db)
settings_store.update(db, {"enabled": False}, key=settings_store.AGENTS)
page = client.get("/chat").text
assert 'id="sidebar-kind-agent"' not in page
def test_the_switch_is_present_when_agent_chats_are_on(client: TestClient, db, registered):
_add_connection(db)
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
page = client.get("/chat").text
assert 'id="sidebar-kind-agent"' in page
assert 'id="sidebar-kind-chat"' in page
# --- The row itself ------------------------------------------------------------
def test_an_agent_chat_is_marked_in_the_row(client: TestClient, db, registered):
"""Legible with the switch off too: a chat that can run commands should not
look like one that cannot."""
_add_connection(db)
_enable_agents(db)
_chat(db, title="A machine errand", kind="agent")
client.post("/api/preferences/sidebar-kind", data={"kind": "agent"})
page = client.get("/chat").text
assert "#i-terminal" in page
# --- The verb goes where the event does ----------------------------------------
def test_the_switch_posts_at_a_route_that_serves_post(client: TestClient, db, registered):
"""Asserted as a refusal as well as a success. A control wired to a method
its route does not serve fails silently -- htmx surfaces nothing, so the
interface looks exactly like one that works."""
assert client.get("/api/preferences/sidebar-kind").status_code == 405
assert client.post("/api/preferences/sidebar-kind", data={"kind": "chat"}).status_code == 200