The outline FAB was position:fixed at z-index 9999 and could stack on top of the scroll-to-bottom button. Anchor it inside the messages shell (position:absolute, z-index 12) so the two controls don't overlap. Co-authored-by: Mohamed Habib <Habib1001-m@users.noreply.github.com>
151 lines
6.6 KiB
Python
151 lines
6.6 KiB
Python
"""Static-analysis tests for the conversation outline panel (issue #2124)."""
|
|
|
|
from pathlib import Path
|
|
import re
|
|
|
|
ROOT = Path(__file__).parent.parent
|
|
STATIC = ROOT / "static"
|
|
INDEX_HTML = (STATIC / "index.html").read_text(encoding="utf-8")
|
|
I18N_JS = (STATIC / "i18n.js").read_text(encoding="utf-8")
|
|
OUTLINE_JS = (STATIC / "outline.js").read_text(encoding="utf-8")
|
|
STYLE_CSS = (STATIC / "style.css").read_text(encoding="utf-8")
|
|
BOOT_JS = (STATIC / "boot.js").read_text(encoding="utf-8")
|
|
PANELS_JS = (STATIC / "panels.js").read_text(encoding="utf-8")
|
|
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
|
|
|
# Number of locale blocks in i18n.js: en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr
|
|
LOCALE_COUNT = 12
|
|
|
|
|
|
def _css_rule_body(selector):
|
|
rule = re.search(re.escape(selector) + r"\{(?P<body>[^}]*)\}", STYLE_CSS, re.S)
|
|
assert rule
|
|
return rule.group("body")
|
|
|
|
|
|
def _css_px(rule_body, property_name):
|
|
match = re.search(rf"{re.escape(property_name)}:(?P<value>\d+)px", rule_body)
|
|
assert match
|
|
return int(match.group("value"))
|
|
|
|
|
|
def test_outline_panel_html_and_i18n_contract():
|
|
"""The panel shell, script tag, and locale keys must be present."""
|
|
assert 'src="static/outline.js?v=__WEBUI_VERSION__"' in INDEX_HTML
|
|
assert "outline.js?v=__WEBUI_VERSION__" in INDEX_HTML
|
|
assert re.search(r'<script[^>]+outline\.js\?v=__WEBUI_VERSION__[^>]+defer', INDEX_HTML)
|
|
assert re.search(r'id="outlineToggleBtn"[^>]*hidden', INDEX_HTML)
|
|
assert re.search(r'id="outlinePanelWrapper"[^>]*hidden', INDEX_HTML)
|
|
assert 'id="outlinePanel"' in INDEX_HTML
|
|
|
|
for key in (
|
|
"outline_title:",
|
|
"outline_empty:",
|
|
"outline_loading:",
|
|
"settings_label_conversation_outline:",
|
|
"settings_desc_conversation_outline:",
|
|
):
|
|
assert I18N_JS.count(key) >= LOCALE_COUNT
|
|
|
|
|
|
def test_outline_setting_round_trip_contract():
|
|
"""The outline preference must default off and use the normal settings path."""
|
|
assert '"show_conversation_outline": False' in CONFIG_PY
|
|
assert '"show_conversation_outline",' in CONFIG_PY
|
|
assert 'id="settingsShowConversationOutline"' in INDEX_HTML
|
|
assert "payload.show_conversation_outline=showConversationOutlineCb.checked;" in PANELS_JS
|
|
assert "settings.show_conversation_outline===true" in PANELS_JS
|
|
assert "body.show_conversation_outline=showConversationOutline===true;" in PANELS_JS
|
|
assert "window._showConversationOutline=s.show_conversation_outline===true" in BOOT_JS
|
|
assert "window._showConversationOutline=false" in BOOT_JS
|
|
|
|
|
|
def test_outline_navigation_and_long_session_contract():
|
|
"""Outline entries must come from session messages and recover off-window targets."""
|
|
for marker in (
|
|
"_outlineSid",
|
|
"window.toggleOutlinePanel",
|
|
"window._outlineJump",
|
|
"S.messages",
|
|
"'msg-user-'",
|
|
"/api/session",
|
|
"_ensureOutlineMessagesLoaded",
|
|
"_ensureAllMessagesLoaded()",
|
|
"_messagesTruncated",
|
|
"_expandOutlineRenderWindow()",
|
|
"_messageRenderWindowSize = Math.max(",
|
|
"renderMessages({ preserveScroll: true })",
|
|
"if (S.busy || S.activeStreamId) return;",
|
|
):
|
|
assert marker in OUTLINE_JS
|
|
|
|
|
|
def test_outline_opt_in_layout_and_render_state_contract():
|
|
"""The enabled outline must stay desktop-only and avoid stale render states."""
|
|
for marker in (
|
|
"window._showConversationOutline === true",
|
|
"toggle.hidden = !enabled",
|
|
"wrapper.hidden = true",
|
|
"window.applyConversationOutlinePreference",
|
|
"matchMedia('(max-width:900px)')",
|
|
"--outline-workspace-offset",
|
|
"panel.offsetWidth",
|
|
"data-workspace-panel",
|
|
"window._outlineRenderHookPending",
|
|
"if (!S.messages) {",
|
|
):
|
|
assert marker in OUTLINE_JS
|
|
|
|
assert "#outlineToggleBtn,#outlinePanelWrapper{display:none!important;}" in STYLE_CSS
|
|
assert "right:calc(var(--outline-workspace-offset, 0px) + 20px)" in STYLE_CSS
|
|
assert "if (!S.messages || !S.messages.length)" not in OUTLINE_JS
|
|
|
|
before_hooked = OUTLINE_JS.index("window._outlineRenderHooked = true")
|
|
before_wrapper = OUTLINE_JS.index("window.renderMessages = function")
|
|
missing_render_block = OUTLINE_JS.split("if (typeof _orig !== 'function')", 1)[1]
|
|
missing_render_block = missing_render_block.split("window._outlineRenderHooked = true", 1)[0]
|
|
assert before_hooked < before_wrapper
|
|
assert "window._outlineRenderHooked = true" not in missing_render_block
|
|
|
|
|
|
def test_outline_is_chat_only_and_closes_on_panel_switch():
|
|
"""The outline is a chat-view affordance: leaving chat must hide the toggle
|
|
AND close the panel, and returning to chat restores the toggle. (Auto-close
|
|
on panel switch — review follow-up.)"""
|
|
# _outlineAllowed() gates on the active panel, not just the setting + width.
|
|
assert "_currentPanel" in OUTLINE_JS
|
|
assert "panel === 'chat'" in OUTLINE_JS
|
|
# Re-evaluated on main-view changes: switchPanel() toggles `showing-<panel>`
|
|
# on <main>, and the MutationObserver watches that class to re-run the gate.
|
|
assert "main.main" in OUTLINE_JS
|
|
assert "attributeFilter: ['class']" in OUTLINE_JS
|
|
|
|
|
|
def test_outline_wrapper_hidden_attr_actually_hides():
|
|
"""The #outlinePanelWrapper id selector sets display:flex, which outranks the
|
|
UA [hidden]{display:none} rule — so the hidden attribute alone would NOT hide
|
|
the panel (the close button / auto-close set wrapper.hidden=true). An explicit
|
|
#outlinePanelWrapper[hidden]{display:none} rule restores the expected behavior."""
|
|
assert "#outlinePanelWrapper[hidden]{display:none;}" in STYLE_CSS
|
|
|
|
|
|
def test_outline_fab_stacks_with_scroll_controls():
|
|
"""The outline FAB shares the messages-shell positioning context with the
|
|
scroll controls, so composer height changes cannot make the controls overlap
|
|
in the bottom-right corner. (#4381)"""
|
|
shell_start = INDEX_HTML.index('<div class="messages-shell">')
|
|
messages_start = INDEX_HTML.index('<div class="messages" id="messages">', shell_start)
|
|
shell_controls = INDEX_HTML[shell_start:messages_start]
|
|
assert 'id="outlineToggleBtn"' in shell_controls
|
|
assert shell_controls.index('id="scrollToBottomBtn"') < shell_controls.index('id="outlineToggleBtn"')
|
|
|
|
outline_css = _css_rule_body("#outlineToggleBtn")
|
|
scroll_css = _css_rule_body(".scroll-to-bottom-btn")
|
|
assert "position:absolute" in outline_css
|
|
assert "position:fixed" not in outline_css
|
|
assert _css_px(outline_css, "right") == _css_px(scroll_css, "right")
|
|
|
|
outline_gap = _css_px(outline_css, "bottom")
|
|
scroll_top = _css_px(scroll_css, "bottom") + _css_px(scroll_css, "height")
|
|
assert outline_gap > scroll_top
|