00ce04addf
Six things, all found by using the thing rather than by reading it. The scope menu only appeared once a chat existed, on the reasoning that there was no row to post to. True, and the wrong conclusion: the harness puts a tool's guidance in front of the model the moment the tool is offered, so the menu could not be reached until after the model had been told how to keep notes and handed the tools to do it -- and switching it off then does not un-send that turn. It is on the new-chat screen now and writes nothing: `_scope_context` builds a stand-in Chat, which is `draft.as_chat`'s trick again, and the switches ride along with the first message. Checked means on and a browser submits only the ticked boxes, so every gate also renders a hidden input naming it and `start_chat` subtracts one list from the other; inverting the control would read backwards under a menu that says everything is on unless you say otherwise. Only the off ones are written, because absent means on and one representation of it is what keeps "why is this off?" to a single answer. Nothing is validated against the offered set, since scope_json narrows after every gate -- naming a gate that was never offered switches off something that was not on. Then the scheduling instructions, audited against a 4B model on this machine rather than against my own reading of them. Ten realistic requests, ten compiled, twice over -- so the prompt is sound. What was not sound was `describe`, which built a phrase by joining fragments and read "Every the 1st at 09:00" for the commonest monthly schedule there is, and "Every of January" for a month with no day. That string is the whole of what somebody sees before approving a schedule and the whole of what the model is told about its own chat, so a phrase nobody can parse is a review step nobody performs. It reads as English now, collapses Monday-to-Friday to "every weekday" and seven days to "every day", and every case in the test is a rule that model actually produced. The one mistake it made was naming Wednesday for "every other tuesday", so the weekday numbering is spelled out rather than left as "0-6, Monday is 0": getting that wrong is the error here that still looks like a working schedule. Roughly one call in six also came back empty -- a local runner swapping models under the request will do that -- so an unusable reply is asked for once more before giving up. Not on an LLMError: an endpoint that refused will refuse again, and the reader is better served by the form than by waiting twice for the same answer. Canvas asked for a typed path, which was the last control in the application expecting somebody to remember an absolute path on another machine -- the same complaint the folder page's directory field answered with a picker. /browse takes pick=file and the same fragment makes files buttons, because a second copy of that listing is a second place for the path arithmetic to be got subtly differently. The button carries data-canvas-open rather than an hx-post since the path is not known until the dialog closes, and ui.js posts it through htmx.ajax so the response lands in the panel exactly as every other canvas action's does. The key is `agent:<path>`, so a file opened by hand and one opened by the model are one tab rather than two spellings of it. The tabs already existed and already closed; they now square off at the bottom and the active one takes the body's background, so which is selected is structural rather than a tint nobody can see in a theme they did not choose. Highlighting was already there for every language named and is checked for fifteen of them. Three smaller ones. Tabs kept their scroll position, so switching from a long panel to a short one left the browser clamping to that panel's bottom: the end of it above a screen of nothing, which reads as a page that failed to load. Nothing in CSS can reset a scroll position. The sidebar's footer and the composer sit either side of one vertical edge and were both content-sized, so their top borders met it at different heights and read as one line that had been broken -- `--footer-height` is a calc of the pieces the footer is built from, applied as a min-height to both, which is exactly what `--header-height` already does at the top of the shell. And "Add a workflow" sat flush against the list it adds to, stated as an adjacency because `.btn-row` is right to carry no margin everywhere else it appears. Both pieces of JavaScript were driven under a DOM stub before committing, which is how the tab listener's delegation and the canvas button's six behaviours were checked at all -- `node --check` parses a file that does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
9.2 KiB
Python
205 lines
9.2 KiB
Python
"""Dialogs and toasts, checked without a runtime.
|
|
|
|
There is no JavaScript test runner here and hard rule 1 keeps Node out of the
|
|
project, so the behaviour is driven by hand under a DOM stub before committing.
|
|
What can be pinned in the suite are the invariants the file states about itself
|
|
-- and in particular the one that would look like an improvement to somebody
|
|
tidying up later.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import lembas
|
|
|
|
ROOT = Path(lembas.__file__).parent
|
|
SOURCE = (ROOT / "web/static/js/ui.js").read_text(encoding="utf-8")
|
|
TEMPLATES = ROOT / "web/templates"
|
|
|
|
|
|
def test_the_dialogs_never_fall_back_to_the_browsers_own():
|
|
"""`window.confirm` and `window.prompt` cannot be styled, ignore the theme
|
|
and block the tab. Putting one back is the thing this module exists to
|
|
prevent."""
|
|
assert "window.confirm(" not in SOURCE
|
|
assert "window.prompt(" not in SOURCE
|
|
|
|
|
|
def test_no_template_uses_htmx_s_own_prompt():
|
|
"""htmx's hx-prompt calls the browser's prompt() *synchronously* and only
|
|
then fires htmx:prompt with the answer already in hand -- so intercepting
|
|
the event cannot supply a different one, and the native box appears
|
|
regardless. `data-prompt` exists because of that, and an hx-prompt slipped
|
|
in later would summon the grey box back with nothing to catch it.
|
|
|
|
hx-confirm is fine and is used widely: that one fires *before*, and ui.js
|
|
intercepts it.
|
|
"""
|
|
# The attribute, not the word: the comment beside `data-prompt` names
|
|
# hx-prompt in order to say why it is not being used.
|
|
offenders = [
|
|
path.relative_to(TEMPLATES)
|
|
for path in TEMPLATES.rglob("*.html")
|
|
if 'hx-prompt="' in path.read_text(encoding="utf-8")
|
|
]
|
|
assert not offenders, f"hx-prompt summons window.prompt: {offenders}"
|
|
|
|
|
|
def test_the_prompt_answer_is_json_encoded_rather_than_concatenated():
|
|
"""A folder called `"` would otherwise produce hx-vals that does not parse,
|
|
and htmx would send the request with the field missing rather than with the
|
|
name -- a rename that silently does nothing."""
|
|
start = SOURCE.index("[data-prompt]")
|
|
block = SOURCE[start : SOURCE.index("data-confirm", start)]
|
|
assert "JSON.stringify" in block
|
|
|
|
|
|
def test_every_prompt_button_names_a_field_or_takes_the_default():
|
|
"""The field name is what the route reads. A button with a field the route
|
|
does not look at posts nothing and looks exactly like one that works."""
|
|
for path in TEMPLATES.rglob("*.html"):
|
|
text = path.read_text(encoding="utf-8")
|
|
if "data-prompt=" not in text:
|
|
continue
|
|
# Either an explicit field, or the "name" default the handler applies.
|
|
assert "data-prompt-field" in text or "/api/folders" in text, path
|
|
|
|
|
|
# --- The transcript's open blocks, and the scroll that used to chase them ------
|
|
STEPS = (ROOT / "web/static/js/steps.js").read_text(encoding="utf-8")
|
|
APP = (ROOT / "web/static/js/app.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_the_toggle_listener_is_registered_in_the_capture_phase():
|
|
"""`toggle` does not bubble. Registered without the third argument the
|
|
listener is never called, in every browser, with nothing anywhere to say so
|
|
-- the same shape as a trigger bound where the event does not go, which cost
|
|
two selects an entire release.
|
|
|
|
Asserted as the property rather than as the markup, for that reason.
|
|
"""
|
|
found = re.search(r'"toggle",[\s\S]{0,600}?\n\s*(true|false)\n\s*\);', APP)
|
|
assert found, "the toggle listener is gone"
|
|
assert found.group(1) == "true"
|
|
|
|
|
|
def test_the_scroll_listener_is_too():
|
|
"""A scroll event does not bubble either, and this one is on the thread
|
|
rather than on the document it is registered against."""
|
|
found = re.search(r'"scroll",[\s\S]{0,600}?\n\s*(true|false)\n\s*\);', APP)
|
|
assert found, "the scroll listener is gone"
|
|
assert found.group(1) == "true"
|
|
|
|
|
|
def test_the_stream_no_longer_scrolls_for_every_frame():
|
|
"""`metrics`, `status`, `ask` and `canvas` all arrive on htmx:sseMessage,
|
|
and none of them changes the height of the thread. Scrolling for all of them
|
|
is what made an opened block impossible to keep on screen."""
|
|
found = re.search(r'htmx:sseMessage", function \(event\) \{([\s\S]{0,400}?)\n \}\);', APP)
|
|
assert found, "the handler is gone"
|
|
assert "thread-scroll" in found.group(1)
|
|
|
|
|
|
def test_the_open_state_is_recorded_before_the_swap_and_restored_after():
|
|
"""Both halves, or it is a module that does nothing. The container is
|
|
replaced with innerHTML twelve times a second, so anything not written down
|
|
first is gone by the time there is somewhere to put it back."""
|
|
assert "htmx:sseBeforeMessage" in STEPS
|
|
assert "htmx:sseMessage" in STEPS
|
|
assert "htmx:afterSwap" in STEPS
|
|
|
|
|
|
def test_it_never_cancels_the_frame_it_is_listening_to():
|
|
"""htmx:sseBeforeMessage is cancellable -- the extension reads what handlers
|
|
return to decide whether to swap at all. Cancelling here would freeze the
|
|
transcript with no error anywhere."""
|
|
assert "preventDefault" not in STEPS
|
|
|
|
|
|
def test_an_id_is_escaped_rather_than_concatenated_into_a_selector():
|
|
"""The same rule `data-prompt` follows for hx-vals. An id is a message id
|
|
and two integers today; a selector assembled by hand is how that stops being
|
|
true safely."""
|
|
assert "CSS.escape" in STEPS
|
|
|
|
|
|
def test_the_finished_bubble_repeats_the_live_container_s_id():
|
|
"""That is the whole mechanism for carrying an opened block across the
|
|
`done` frame, which replaces the entire article: same id on the container,
|
|
same ids inside it, because both come from the mark index."""
|
|
message = (TEMPLATES / "chat/_message.html").read_text(encoding="utf-8")
|
|
|
|
assert message.count('id="steps-{{ message.id }}" data-steps') == 2
|
|
|
|
|
|
def test_switching_a_tab_puts_its_body_back_at_the_top():
|
|
"""A tab is a radio and a panel is shown by CSS, so switching one changes
|
|
nothing about `.tabs__body` -- the element that actually scrolls. Read half
|
|
way down a long panel, switch to a short one, and the browser clamps the
|
|
kept scrollTop to that panel's bottom: what lands on screen is the end of it
|
|
above a screen of nothing, which reads as a page that failed to load.
|
|
|
|
Nothing in CSS can reset a scroll position. Driven under a DOM stub before
|
|
committing; what is pinned here is that the listener is delegated and keyed
|
|
on the class rather than on one page's ids, because the next tabbed screen
|
|
would otherwise have the same bug and no sign of it.
|
|
"""
|
|
assert '.closest(".tabs__bar")' in SOURCE or 'closest(".tabs__bar")' in SOURCE
|
|
assert 'querySelector(".tabs__body")' in SOURCE
|
|
assert "scrollTop = 0" in SOURCE
|
|
|
|
|
|
def test_the_shell_has_one_line_along_its_bottom_edge():
|
|
"""The sidebar footer and the composer sit either side of the same vertical
|
|
edge and were both content-sized, so their top borders met it at different
|
|
heights and read as one line that had been broken.
|
|
|
|
Neither can match the other by accident: the footer's height depends on
|
|
which entries a reader's permissions allow, and the composer's on how much
|
|
has been typed. So both take a `min-height` from one token, the way
|
|
`--header-height` already does this at the top of the shell.
|
|
"""
|
|
tokens = (ROOT / "web/static/css/tokens.css").read_text(encoding="utf-8")
|
|
app = (ROOT / "web/static/css/app.css").read_text(encoding="utf-8")
|
|
chat = (ROOT / "web/static/css/chat.css").read_text(encoding="utf-8")
|
|
|
|
assert "--footer-height:" in tokens
|
|
# A calc of the pieces the footer is built from, not a measured constant --
|
|
# a number would stop being true the moment `--control-h` moved.
|
|
assert "var(--control-h)" in tokens.split("--footer-height:")[1].split(";")[0]
|
|
assert "min-height: var(--footer-height)" in app
|
|
assert "min-height: var(--footer-height)" in chat
|
|
|
|
|
|
def test_the_canvas_open_button_posts_through_htmx():
|
|
"""The button cannot carry an `hx-post`: the path is not known until the
|
|
dialog closes. So it posts afterwards — through htmx's own `ajax`, so the
|
|
response lands in the panel exactly as every other canvas action's does.
|
|
|
|
`fetch` would mean parsing and swapping the fragment by hand, and then there
|
|
would be two ways the canvas gets replaced. Driven under a DOM stub before
|
|
committing; what is pinned here is the shape that keeps them one.
|
|
"""
|
|
assert "window.htmx.ajax(" in SOURCE
|
|
assert '"#canvas-inner"' in SOURCE
|
|
# The key is `agent:<path>` -- the same key a tool call's read produces, so
|
|
# a file opened here and one opened by the model are one tab rather than two
|
|
# spellings of it. That is `canvas.path_key`'s whole job.
|
|
assert '"agent:" + path' in SOURCE
|
|
|
|
|
|
def test_the_file_picker_asks_for_files():
|
|
"""One listing serves the directory picker and the file picker, because a
|
|
second copy is a second place for the path arithmetic to be got subtly
|
|
differently — and getting it differently means a file that opens to the
|
|
wrong path, or to nothing."""
|
|
app = (ROOT / "web/static/js/app.js").read_text(encoding="utf-8")
|
|
|
|
assert "pick=file" in app
|
|
assert "data-file-open" in app or "dataset.fileOpen" in app
|
|
# Directories stay a step in file mode, or a file two folders down is
|
|
# unreachable.
|
|
assert app.count("[data-dir-open]") >= 2
|