- index.html: btnSend hidden by default (display:none), icon-only (upward arrow SVG, no text label), title attribute for accessibility - style.css: new send-btn design — 34px circle, blue fill (#7cb9ff), subtle glow box-shadow, scale() hover/active for tactile feel, .send-btn.visible with @keyframes send-pop-in (scale+opacity spring using cubic-bezier(.34,1.56,.64,1) for a satisfying pop). Mobile override updated to preserve circle dimensions. - ui.js: updateSendBtn() — shows button with pop-in animation when textarea has content OR files are attached and agent is not busy; hides instantly when content is cleared. Hooked into setBusy() and renderTray() so button state tracks all content sources correctly. - boot.js: input event listener calls updateSendBtn() on every keystroke. - messages.js: autoResize() calls updateSendBtn() so button disappears immediately after send clears the textarea. - tests/test_sprint21.py: 33 tests covering HTML structure, CSS design (circle shape, colors, animations, keyframes), JS logic (updateSendBtn, setBusy, renderTray, autoResize integration), and regressions (363 total, all pass).
350 lines
12 KiB
Python
350 lines
12 KiB
Python
"""
|
|
Sprint 21 Tests: Send button polish — hidden until content, pop-in animation,
|
|
icon-only circle design.
|
|
"""
|
|
import re
|
|
import urllib.request
|
|
|
|
BASE = "http://127.0.0.1:8788"
|
|
|
|
|
|
def get_text(path):
|
|
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
|
return r.read().decode(), r.status
|
|
|
|
|
|
# ── index.html ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_send_button_present():
|
|
"""btnSend must still exist in the DOM."""
|
|
html, status = get_text("/")
|
|
assert status == 200
|
|
assert 'id="btnSend"' in html
|
|
|
|
|
|
def test_send_button_hidden_by_default():
|
|
"""btnSend must start hidden (display:none) — only shown when there is content."""
|
|
html, _ = get_text("/")
|
|
btn_match = re.search(r'id="btnSend"[^>]*>', html)
|
|
assert btn_match, "btnSend element not found"
|
|
assert 'display:none' in btn_match.group(0)
|
|
|
|
|
|
def test_send_button_no_text_label():
|
|
"""Send button must be icon-only — no visible 'Send' text label."""
|
|
html, _ = get_text("/")
|
|
# Find the full button element (from opening tag to closing tag)
|
|
btn_open_end = html.find('>', html.find('id="btnSend"')) + 1
|
|
btn_end = html.find('</button>', btn_open_end) + len('</button>')
|
|
btn_inner = html[btn_open_end:btn_end]
|
|
# Strip SVG content and any remaining tags; check visible text
|
|
no_svg = re.sub(r'<svg[^>]*>.*?</svg>', '', btn_inner, flags=re.DOTALL)
|
|
visible_text = re.sub(r'<[^>]+>', '', no_svg).strip()
|
|
assert visible_text == '', f"Send button has visible text: {visible_text!r}"
|
|
|
|
|
|
def test_send_button_has_svg_icon():
|
|
"""Send button must have an SVG icon."""
|
|
html, _ = get_text("/")
|
|
btn_start = html.find('id="btnSend"')
|
|
btn_end = html.find('</button>', btn_start) + len('</button>')
|
|
btn_html = html[btn_start:btn_end]
|
|
assert '<svg' in btn_html
|
|
|
|
|
|
def test_send_button_has_title_attribute():
|
|
"""btnSend must have a title attribute for accessibility (replaces text label)."""
|
|
html, _ = get_text("/")
|
|
btn_match = re.search(r'id="btnSend"[^>]*>', html)
|
|
assert btn_match
|
|
assert 'title=' in btn_match.group(0)
|
|
|
|
|
|
def test_send_button_svg_arrow_up():
|
|
"""Send button SVG should use an upward arrow (line + polyline or path)."""
|
|
html, _ = get_text("/")
|
|
btn_start = html.find('id="btnSend"')
|
|
btn_end = html.find('</button>', btn_start) + len('</button>')
|
|
btn_html = html[btn_start:btn_end]
|
|
# Must have some directional shape element
|
|
has_shape = ('<line' in btn_html or '<polyline' in btn_html or
|
|
'<polygon' in btn_html or '<path' in btn_html)
|
|
assert has_shape, "Send button SVG missing directional shape"
|
|
|
|
|
|
# ── style.css ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_send_btn_is_circle():
|
|
"""send-btn must use border-radius:50% for the circle shape."""
|
|
css, status = get_text("/static/style.css")
|
|
assert status == 200
|
|
send_idx = css.find('.send-btn{')
|
|
brace_open = css.find('{', send_idx)
|
|
brace_close = css.find('}', brace_open)
|
|
rule = css[brace_open:brace_close]
|
|
assert 'border-radius:50%' in rule or 'border-radius: 50%' in rule
|
|
|
|
|
|
def test_send_btn_fixed_dimensions():
|
|
"""send-btn must have explicit width and height (icon-circle, not text-padded)."""
|
|
css, _ = get_text("/static/style.css")
|
|
send_idx = css.find('.send-btn{')
|
|
brace_open = css.find('{', send_idx)
|
|
brace_close = css.find('}', brace_open)
|
|
rule = css[brace_open:brace_close]
|
|
assert 'width:' in rule or 'width :' in rule
|
|
assert 'height:' in rule or 'height :' in rule
|
|
|
|
|
|
def test_send_btn_no_old_padding():
|
|
"""send-btn must not use text padding layout (old pill style removed)."""
|
|
css, _ = get_text("/static/style.css")
|
|
send_idx = css.find('.send-btn{')
|
|
brace_open = css.find('{', send_idx)
|
|
brace_close = css.find('}', brace_open)
|
|
rule = css[brace_open:brace_close]
|
|
# Old style used padding:7px 18px — should be gone
|
|
assert 'padding:7px' not in rule and 'padding: 7px' not in rule
|
|
|
|
|
|
def test_send_btn_blue_background():
|
|
"""send-btn background must use the blue accent (#7cb9ff or similar)."""
|
|
css, _ = get_text("/static/style.css")
|
|
send_idx = css.find('.send-btn{')
|
|
brace_open = css.find('{', send_idx)
|
|
brace_close = css.find('}', brace_open)
|
|
rule = css[brace_open:brace_close]
|
|
assert '7cb9ff' in rule or '5ba8f5' in rule or 'var(--blue)' in rule
|
|
|
|
|
|
def test_send_btn_has_transition():
|
|
"""send-btn must have transition for smooth hover/active states."""
|
|
css, _ = get_text("/static/style.css")
|
|
send_idx = css.find('.send-btn{')
|
|
brace_open = css.find('{', send_idx)
|
|
brace_close = css.find('}', brace_open)
|
|
rule = css[brace_open:brace_close]
|
|
assert 'transition' in rule
|
|
|
|
|
|
def test_send_btn_has_box_shadow():
|
|
"""send-btn must have a box-shadow glow effect."""
|
|
css, _ = get_text("/static/style.css")
|
|
send_idx = css.find('.send-btn{')
|
|
brace_open = css.find('{', send_idx)
|
|
brace_close = css.find('}', brace_open)
|
|
rule = css[brace_open:brace_close]
|
|
assert 'box-shadow' in rule
|
|
|
|
|
|
def test_send_btn_hover_has_scale():
|
|
"""send-btn:hover must use transform:scale for a satisfying hover effect."""
|
|
css, _ = get_text("/static/style.css")
|
|
hover_idx = css.find('.send-btn:hover{')
|
|
brace_open = css.find('{', hover_idx)
|
|
brace_close = css.find('}', brace_open)
|
|
rule = css[brace_open:brace_close]
|
|
assert 'scale' in rule
|
|
|
|
|
|
def test_send_btn_active_shrinks():
|
|
"""send-btn:active must scale down slightly for tactile press feedback."""
|
|
css, _ = get_text("/static/style.css")
|
|
active_idx = css.find('.send-btn:active{')
|
|
brace_open = css.find('{', active_idx)
|
|
brace_close = css.find('}', brace_open)
|
|
rule = css[brace_open:brace_close]
|
|
assert 'scale' in rule
|
|
|
|
|
|
def test_send_btn_disabled_rule_exists():
|
|
"""send-btn:disabled must still be styled."""
|
|
css, _ = get_text("/static/style.css")
|
|
assert '.send-btn:disabled' in css
|
|
|
|
|
|
def test_send_btn_visible_class_defined():
|
|
""".send-btn.visible class must be defined for the pop-in animation."""
|
|
css, _ = get_text("/static/style.css")
|
|
assert '.send-btn.visible' in css
|
|
|
|
|
|
def test_send_pop_in_keyframes_defined():
|
|
"""@keyframes send-pop-in must be defined."""
|
|
css, _ = get_text("/static/style.css")
|
|
assert 'send-pop-in' in css
|
|
assert '@keyframes' in css
|
|
|
|
|
|
def test_send_pop_in_uses_scale():
|
|
"""send-pop-in keyframe must animate from a scaled-down state."""
|
|
css, _ = get_text("/static/style.css")
|
|
kf_idx = css.find('send-pop-in')
|
|
kf_start = css.rfind('@keyframes', 0, kf_idx)
|
|
# Find matching closing brace (simple scan)
|
|
depth = 0
|
|
kf_end = kf_start
|
|
for i, ch in enumerate(css[kf_start:], kf_start):
|
|
if ch == '{':
|
|
depth += 1
|
|
elif ch == '}':
|
|
depth -= 1
|
|
if depth == 0:
|
|
kf_end = i
|
|
break
|
|
kf_rule = css[kf_start:kf_end]
|
|
assert 'scale' in kf_rule
|
|
|
|
|
|
def test_send_pop_in_uses_opacity():
|
|
"""send-pop-in keyframe must fade in (opacity transition)."""
|
|
css, _ = get_text("/static/style.css")
|
|
kf_idx = css.find('send-pop-in')
|
|
kf_start = css.rfind('@keyframes', 0, kf_idx)
|
|
depth = 0
|
|
kf_end = kf_start
|
|
for i, ch in enumerate(css[kf_start:], kf_start):
|
|
if ch == '{':
|
|
depth += 1
|
|
elif ch == '}':
|
|
depth -= 1
|
|
if depth == 0:
|
|
kf_end = i
|
|
break
|
|
kf_rule = css[kf_start:kf_end]
|
|
assert 'opacity' in kf_rule
|
|
|
|
|
|
def test_send_btn_mobile_override_no_padding():
|
|
"""Mobile override for send-btn must not add text padding (keeps circle shape)."""
|
|
css, _ = get_text("/static/style.css")
|
|
# Find the @media block
|
|
media_idx = css.find('@media')
|
|
send_mobile_idx = css.find('.send-btn', media_idx)
|
|
if send_mobile_idx == -1:
|
|
return # No mobile override, fine
|
|
brace_open = css.find('{', send_mobile_idx)
|
|
brace_close = css.find('}', brace_open)
|
|
rule = css[brace_open:brace_close]
|
|
assert 'padding:' not in rule and 'font-size' not in rule
|
|
|
|
|
|
# ── ui.js ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_ui_js_update_send_btn_function():
|
|
"""ui.js must define updateSendBtn() function."""
|
|
js, status = get_text("/static/ui.js")
|
|
assert status == 200
|
|
assert 'function updateSendBtn' in js
|
|
|
|
|
|
def test_update_send_btn_checks_content():
|
|
"""updateSendBtn must check textarea value length."""
|
|
js, _ = get_text("/static/ui.js")
|
|
fn_idx = js.find('function updateSendBtn')
|
|
fn_end = js.find('\n}', fn_idx) + 2
|
|
fn_body = js[fn_idx:fn_end]
|
|
assert 'msg' in fn_body
|
|
assert '.value' in fn_body
|
|
assert '.length' in fn_body or '.trim()' in fn_body
|
|
|
|
|
|
def test_update_send_btn_checks_pending_files():
|
|
"""updateSendBtn must also show send button when files are attached."""
|
|
js, _ = get_text("/static/ui.js")
|
|
fn_idx = js.find('function updateSendBtn')
|
|
fn_end = js.find('\n}', fn_idx) + 2
|
|
fn_body = js[fn_idx:fn_end]
|
|
assert 'pendingFiles' in fn_body
|
|
|
|
|
|
def test_update_send_btn_uses_visible_class():
|
|
"""updateSendBtn must add .visible class to trigger the pop-in animation."""
|
|
js, _ = get_text("/static/ui.js")
|
|
fn_idx = js.find('function updateSendBtn')
|
|
fn_end = js.find('\n}', fn_idx) + 2
|
|
fn_body = js[fn_idx:fn_end]
|
|
assert 'visible' in fn_body
|
|
|
|
|
|
def test_update_send_btn_uses_display_none():
|
|
"""updateSendBtn must hide the button with display:none when no content."""
|
|
js, _ = get_text("/static/ui.js")
|
|
fn_idx = js.find('function updateSendBtn')
|
|
fn_end = js.find('\n}', fn_idx) + 2
|
|
fn_body = js[fn_idx:fn_end]
|
|
assert 'display' in fn_body
|
|
assert 'none' in fn_body
|
|
|
|
|
|
def test_set_busy_calls_update_send_btn():
|
|
"""setBusy must call updateSendBtn() so button hides while agent is responding."""
|
|
js, _ = get_text("/static/ui.js")
|
|
busy_idx = js.find('function setBusy')
|
|
busy_end = js.find('\n}', busy_idx) + 2
|
|
busy_body = js[busy_idx:busy_end]
|
|
assert 'updateSendBtn' in busy_body
|
|
|
|
|
|
def test_render_tray_calls_update_send_btn():
|
|
"""renderTray must call updateSendBtn() so button appears when files are attached."""
|
|
js, _ = get_text("/static/ui.js")
|
|
tray_idx = js.find('function renderTray')
|
|
tray_end = js.find('\n}', tray_idx) + 2
|
|
tray_body = js[tray_idx:tray_end]
|
|
assert 'updateSendBtn' in tray_body
|
|
|
|
|
|
# ── boot.js ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_boot_js_input_calls_update_send_btn():
|
|
"""boot.js input event listener must call updateSendBtn()."""
|
|
js, status = get_text("/static/boot.js")
|
|
assert status == 200
|
|
assert 'updateSendBtn' in js
|
|
|
|
|
|
# ── messages.js ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_auto_resize_calls_update_send_btn():
|
|
"""autoResize() must call updateSendBtn() so button hides after send clears textarea."""
|
|
js, status = get_text("/static/messages.js")
|
|
assert status == 200
|
|
assert 'updateSendBtn' in js
|
|
|
|
|
|
# ── Regression: existing behaviour unchanged ──────────────────────────────
|
|
|
|
|
|
def test_send_button_still_has_send_btn_class():
|
|
"""btnSend must still carry class='send-btn' for CSS targeting."""
|
|
html, _ = get_text("/")
|
|
assert 'class="send-btn"' in html
|
|
|
|
|
|
def test_ui_js_set_busy_still_disables_btn():
|
|
"""setBusy must still set btnSend.disabled (not just hide it)."""
|
|
js, _ = get_text("/static/ui.js")
|
|
busy_idx = js.find('function setBusy')
|
|
busy_end = js.find('\n}', busy_idx) + 2
|
|
busy_body = js[busy_idx:busy_end]
|
|
assert "btnSend" in busy_body
|
|
assert 'disabled' in busy_body
|
|
|
|
|
|
def test_index_html_attach_button_unchanged():
|
|
"""btnAttach must still be present (no regression)."""
|
|
html, _ = get_text("/")
|
|
assert 'id="btnAttach"' in html
|
|
|
|
|
|
def test_send_function_still_exists():
|
|
"""send() function must still be defined in messages.js."""
|
|
js, _ = get_text("/static/messages.js")
|
|
assert 'async function send()' in js
|