mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-27 06:14:48 -06:00
206 lines
8.1 KiB
Python
206 lines
8.1 KiB
Python
"""Guards for the shared conversational-pane card sheet
|
|
(``turnstone/shared_static/conversation.css``).
|
|
|
|
Born in step 5e.2a: the ONE neutral ``.conv-*`` approval-card vocabulary both
|
|
panes emit, converging the forked ``.coord-tool-*`` (coordinator.css) and
|
|
``.ts-approval-*`` / ``.verdict-*`` (chat.css + interactive.css) cards. These
|
|
pin the load-bearing invariants — the DS button rule (approve == --ok, never
|
|
--warn), the core selector set, a self-contained spinner keyframe, and the
|
|
three-page link wiring — so a regression fails loudly here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_ROOT = Path(__file__).resolve().parent.parent
|
|
_CSS = _ROOT / "turnstone/shared_static/conversation.css"
|
|
_CHAT_CSS = _ROOT / "turnstone/shared_static/chat.css"
|
|
_INTERACTIVE_CSS = _ROOT / "turnstone/shared_static/interactive.css"
|
|
_PAGES = (
|
|
_ROOT / "turnstone/console/static/index.html",
|
|
_ROOT / "turnstone/console/static/coordinator/index.html",
|
|
_ROOT / "turnstone/ui/static/index.html",
|
|
)
|
|
|
|
|
|
def _css() -> str:
|
|
return _CSS.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_core_selectors_present() -> None:
|
|
"""The card's structural vocabulary — drop one and the matching builder's
|
|
output goes unstyled in both panes."""
|
|
body = _css()
|
|
for sel in (
|
|
".conv-batch",
|
|
".conv-batch-head",
|
|
".conv-row",
|
|
".conv-row-call",
|
|
".conv-verdict",
|
|
".conv-verdict-detail",
|
|
".conv-warning",
|
|
".conv-actions",
|
|
".conv-btn",
|
|
".conv-status",
|
|
):
|
|
assert sel + " " in body or sel + "," in body or sel + "{" in body, (
|
|
f"conversation.css missing {sel}"
|
|
)
|
|
|
|
|
|
def test_pane_messages_pins_children_flex_shrink() -> None:
|
|
"""Regression guard: tool cards collapsing to a ~2px empty stripe. The
|
|
interactive message list is a SCROLLING flex column, and .conv-batch sets
|
|
``overflow:hidden`` — whose flex ``min-height:auto`` resolves to 0, so
|
|
without an explicit ``flex-shrink:0`` the tool batch gets squished to just
|
|
its (left-)border once the column fills. Plain .msg blocks (overflow
|
|
visible) are immune, which is why the bug looked interactive-only. Don't
|
|
drop the pin."""
|
|
css = _INTERACTIVE_CSS.read_text(encoding="utf-8")
|
|
sel = ".pane--embedded .pane-messages > *"
|
|
assert sel in css, f"interactive.css must pin {sel} so cards don't collapse"
|
|
block = css[css.index(sel) : css.index(sel) + 120]
|
|
assert "flex-shrink: 0" in block, f"{sel} must set flex-shrink: 0"
|
|
|
|
|
|
def test_approve_uses_ok_not_warn() -> None:
|
|
"""Load-bearing DS hard-rule (base.css:84): the Approve button is GREEN
|
|
(--ok), never amber (--warn). Pin the whole button trio's semantics:
|
|
Approve = --ok fill, Approve all = dashed --ok ghost, Deny = --err."""
|
|
body = _css()
|
|
approve = _rule_body(body, ".conv-btn--approve")
|
|
assert "--ok" in approve, "Approve button must use --ok"
|
|
assert "--warn" not in approve, "Approve button must NOT use --warn (DS rule)"
|
|
|
|
always = _rule_body(body, ".conv-btn--always")
|
|
assert "dashed" in always, "Approve all must be a dashed ghost"
|
|
assert "--ok" in always, "Approve all must use --ok (it is an approve action)"
|
|
|
|
deny = _rule_body(body, ".conv-btn--deny")
|
|
assert "--err" in deny, "Deny button must use --err"
|
|
|
|
|
|
def test_state_stripe_vocabulary() -> None:
|
|
"""The batch state left-stripe — the primary non-text WCAG 1.4.1 cue."""
|
|
body = _css()
|
|
assert "--warn" in _rule_body(body, ".conv-batch--pending")
|
|
assert "--ok" in _rule_body(body, ".conv-batch--approved")
|
|
assert "--err" in (
|
|
_rule_body(body, ".conv-batch--denied") + _rule_body(body, ".conv-batch--error")
|
|
)
|
|
|
|
|
|
def test_spinner_keyframe_is_self_contained() -> None:
|
|
"""The verdict spinner must NOT depend on coord-chrome.css's ``ts-spin``
|
|
keyframe — that sheet isn't loaded by the standalone interactive pane. The
|
|
sheet defines + uses its own namespaced ``conv-spin``."""
|
|
body = _css()
|
|
assert "@keyframes conv-spin" in body
|
|
assert "animation: conv-spin" in body
|
|
# The comment may NAME ts-spin to explain the namespacing; what must not
|
|
# appear is an actual dependency on it (a reference or a redefinition).
|
|
assert "animation: ts-spin" not in body
|
|
assert "@keyframes ts-spin" not in body
|
|
|
|
|
|
_COMPACT_FAIL_OPEN_TOKENS = (
|
|
".conv-batch--pending",
|
|
".conv-batch--running",
|
|
".conv-batch--denied",
|
|
".conv-batch--error",
|
|
'[aria-busy="true"]',
|
|
".conv-actions",
|
|
".conv-verdict-spinner",
|
|
".conv-warning",
|
|
".conv-row.error",
|
|
".conv-row-result--error",
|
|
".conv-row-status--error",
|
|
".conv-status--error",
|
|
'.conv-row[data-tool-name="task_agent"]',
|
|
'.conv-agent[data-state="running"]',
|
|
'[data-agent-step-exceptional="true"]',
|
|
".compaction-running",
|
|
".conv-agent-compaction-notice",
|
|
'[data-output-review-incomplete="true"]',
|
|
".conv-verdict--high",
|
|
".conv-verdict--critical",
|
|
".conv-verdict-rec--deny",
|
|
".conv-verdict-rec--review",
|
|
'[data-effect-status="committed"]',
|
|
)
|
|
|
|
|
|
def _compact_section(css: str) -> str:
|
|
start = css.index("/* Compact transcript presentation.")
|
|
end = css.index("/* Row container.", start)
|
|
return css[start:end]
|
|
|
|
|
|
def _assert_compact_fail_open_contract(section: str) -> None:
|
|
assert ':root[data-transcript-presentation="compact"]' in section
|
|
assert "[data-transcript-root]" in section
|
|
assert '[data-results-settled="true"]' in section
|
|
assert '[data-compact-folded="true"]' in section
|
|
assert "> :not(.conv-batch-head)" in section
|
|
for token in _COMPACT_FAIL_OPEN_TOKENS:
|
|
assert section.count(token) >= 2, f"compact selectors lost mirrored fail-open term {token}"
|
|
|
|
|
|
def test_compact_batch_fold_is_explicit_scoped_and_fail_open() -> None:
|
|
_assert_compact_fail_open_contract(_compact_section(_css()))
|
|
|
|
|
|
@pytest.mark.parametrize("token", _COMPACT_FAIL_OPEN_TOKENS)
|
|
def test_compact_contract_guard_detects_each_missing_exclusion(token: str) -> None:
|
|
section = _compact_section(_css()).replace(token, "")
|
|
with pytest.raises(AssertionError):
|
|
_assert_compact_fail_open_contract(section)
|
|
|
|
|
|
def test_compact_message_density_cannot_match_shell_status_messages() -> None:
|
|
css = re.sub(r"\s+", " ", _CHAT_CSS.read_text(encoding="utf-8"))
|
|
prefix = ':root[data-transcript-presentation="compact"] [data-transcript-root]'
|
|
assert prefix + " .msg {" in css
|
|
assert prefix + " .msg.reasoning {" in css
|
|
assert prefix + ' .msg.reasoning[data-reasoning-active="true"] {' in css
|
|
assert prefix + ' .msg.reasoning[data-reasoning-active="true"] > .msg-body {' in css
|
|
assert (
|
|
prefix + ' .msg.reasoning[data-reasoning-active="true"] > .reasoning-activity-status {'
|
|
in css
|
|
)
|
|
assert prefix + ' .msg.reasoning[data-reasoning-active="true"] > * {' not in css
|
|
assert ".reasoning-activity-status {" in css
|
|
assert "@keyframes transcript-reasoning-spin" in css
|
|
assert "prefers-reduced-motion: reduce" in css
|
|
assert ':root[data-transcript-presentation="compact"] .msg {' not in css
|
|
|
|
|
|
def test_linked_by_console_and_both_standalone_pages() -> None:
|
|
"""Loaded everywhere a ``.conv-*`` emitter renders: the console (hosts both
|
|
panes), the standalone coordinator page, and the standalone interactive page
|
|
(ui/static, driven by the same interactive.js)."""
|
|
for page in _PAGES:
|
|
html = page.read_text(encoding="utf-8")
|
|
assert "/shared/conversation.css" in html, f"{page.name} must link conversation.css"
|
|
|
|
|
|
def _rule_body(css: str, selector: str) -> str:
|
|
"""Return the declaration block for a selector (first match).
|
|
|
|
Tolerates a grouped selector list (``.conv-batch--denied,\\n.conv-batch--error
|
|
{...}``): the optional ``,...`` clause lets the queried selector sit anywhere
|
|
in the list. A descendant rule (``.conv-batch--denied .conv-row {...}``) is
|
|
skipped — a space (not a comma) before the next token fails both the optional
|
|
group and the bare ``{``, so ``search`` advances to the real rule.
|
|
"""
|
|
pattern = re.compile(
|
|
re.escape(selector) + r"(?:\s*,\s*[^{]+)?\s*\{([^}]*)\}",
|
|
)
|
|
m = pattern.search(css)
|
|
assert m, f"selector {selector} not found as a rule"
|
|
return m.group(1)
|