2026-06-21 19:42:42 +00:00
""" Static-contract regression tests for the composer footer-control visibility
toggles ( #4598).
Mirrors the sibling pattern in test_sidebar_tab_visibility . py : pins that each
toggle is wired end - to - end ( config boolean key - > boot . js definition + read - back
- > index . html control - > panels . js chip render - > apply ) and that every new i18n
key exists across all locale blocks , so a future refactor can ' t silently orphan
a control or break locale parity .
"""
2026-07-01 02:11:54 +00:00
import json
import shutil
import subprocess
import textwrap
2026-06-21 19:42:42 +00:00
from pathlib import Path
2026-07-01 02:11:54 +00:00
import pytest
2026-06-21 19:42:42 +00:00
ROOT = Path ( __file__ ) . resolve ( ) . parents [ 1 ]
CONFIG_PY = ( ROOT / " api " / " config.py " ) . read_text ( encoding = " utf-8 " )
PANELS_JS = ( ROOT / " static " / " panels.js " ) . read_text ( encoding = " utf-8 " )
BOOT_JS = ( ROOT / " static " / " boot.js " ) . read_text ( encoding = " utf-8 " )
INDEX_HTML = ( ROOT / " static " / " index.html " ) . read_text ( encoding = " utf-8 " )
I18N_JS = ( ROOT / " static " / " i18n.js " ) . read_text ( encoding = " utf-8 " )
2026-07-01 02:11:54 +00:00
STYLE_CSS = ( ROOT / " static " / " style.css " ) . read_text ( encoding = " utf-8 " )
2026-06-21 19:42:42 +00:00
# The 15 composer-control visibility flags this feature ships.
HIDE_KEYS = [
" hide_composer_attach " ,
" hide_composer_saved_prompts " ,
" hide_composer_mic " ,
" hide_composer_voice_mode " ,
" hide_composer_yolo " ,
" hide_composer_profile " ,
" hide_composer_workspace " ,
" hide_composer_mobile_config " ,
" hide_composer_model " ,
" hide_composer_quota_chip " ,
" hide_composer_reasoning " ,
" hide_composer_toolsets " ,
" hide_composer_status " ,
" hide_composer_context " ,
" hide_composer_bg_badge " ,
]
# The new i18n keys the feature adds (section labels/descriptions + per-chip labels).
I18N_KEYS = [
" settings_label_composer_controls " ,
" settings_desc_composer_controls " ,
" settings_label_composer_situational_controls " ,
" settings_desc_composer_situational_controls " ,
" composer_control_attach " ,
" composer_control_saved_prompts " ,
" composer_control_mic " ,
" composer_control_profile " ,
" composer_control_workspace " ,
" composer_control_model " ,
" composer_control_reasoning " ,
" composer_control_context " ,
" composer_control_voice_mode " ,
" composer_control_yolo " ,
" composer_control_bg_badge " ,
" composer_control_mobile_config " ,
" composer_control_quota_chip " ,
" composer_control_toolsets " ,
" composer_control_status " ,
]
2026-07-01 02:11:54 +00:00
def _extract_function ( source : str , name : str ) - > str :
marker = f " function { name } ( "
start = source . index ( marker )
brace = source . index ( " { " , start )
depth = 0
for idx in range ( brace , len ( source ) ) :
char = source [ idx ]
if char == " { " :
depth + = 1
elif char == " } " :
depth - = 1
if depth == 0 :
return source [ start : idx + 1 ]
raise AssertionError ( f " Could not extract { name } " )
2026-06-21 19:42:42 +00:00
def test_all_hide_flags_registered_as_boolean_settings_keys ( ) :
""" Every toggle must be in config.py ' s boolean-keys set so it persists and
round - trips through save / load . """
for key in HIDE_KEYS :
assert f ' " { key } " ' in CONFIG_PY , f " { key } missing from config.py boolean settings keys "
def test_hide_composer_send_orphan_key_fully_removed ( ) :
""" The re-push removed the orphaned hide_composer_send key (Send is always
visible ) — it must not linger anywhere . """
assert " hide_composer_send " not in CONFIG_PY
assert " hide_composer_send " not in BOOT_JS
assert " hide_composer_send " not in PANELS_JS
def test_footer_control_chips_rendered_in_panels ( ) :
""" panels.js must render the primary + situational control chips and apply
the visibility settings live . """
assert " _renderComposerControlChips " in PANELS_JS
assert " _renderComposerSituationalControlChips " in PANELS_JS
assert " _ensureComposerControlVisibilityState " in PANELS_JS
assert " _applyComposerFooterVisibilitySettings " in PANELS_JS
2026-07-01 02:11:54 +00:00
def test_composer_control_order_frontend_contracts ( ) :
""" Footer reorder must coexist with the now-required situational chips. """
assert " composer_control_order " in CONFIG_PY
assert " composer_control_order " in BOOT_JS
assert " composer_control_order " in PANELS_JS
assert " _sanitizeComposerControlOrder " in BOOT_JS
assert " _orderedComposerControlDefs " in BOOT_JS
assert " _applyComposerControlOrder " in BOOT_JS
assert " orderSelector " in BOOT_JS
assert " orderGroup " in BOOT_JS
assert " _COMPOSER_CONTROL_ORDER_LS_KEY " in PANELS_JS
assert " _orderedComposerControlDefsForSettings " in PANELS_JS
assert " _wireComposerControlChipDrag " in PANELS_JS
assert " _moveComposerControlOrderKey " in PANELS_JS
assert " _composerControlDropAllowed " in PANELS_JS
assert " _renderComposerSituationalControlChips " in PANELS_JS
assert " composerSituationalControlsChips " in PANELS_JS
assert " #composerControlsChips .tab-visibility-chip " in STYLE_CSS
assert " #composerSituationalControlsChips .tab-visibility-chip " in STYLE_CSS
render_primary = _extract_function ( PANELS_JS , " _renderComposerControlChips " )
render_situational = _extract_function ( PANELS_JS , " _renderComposerSituationalControlChips " )
for body in ( render_primary , render_situational ) :
assert " _orderedComposerControlDefsForSettings " in body
assert " _wireComposerControlChipDrag " in body
move_body = _extract_function ( PANELS_JS , " _moveComposerControlOrderKey " )
assert " _renderComposerControlChips() " in move_body
assert " _renderComposerSituationalControlChips() " in move_body
assert " _scheduleAppearanceAutosave() " in move_body
2026-06-21 19:42:42 +00:00
def test_new_i18n_keys_exist_across_all_locale_blocks ( ) :
""" Every new i18n key must appear in all 13 locale blocks (strict locale
parity ) , not just ` en ` — otherwise the locale - coverage suite goes red . """
# 13 locale blocks (en + 12). Each key should appear at least 13 times.
for key in I18N_KEYS :
count = I18N_JS . count ( f " { key } : " )
assert count > = 13 , (
f " { key } appears { count } x in i18n.js — expected >=13 (one per locale "
f " block) for strict locale parity "
)
2026-07-01 02:11:54 +00:00
assert " Drag chips to reorder each footer group. " in INDEX_HTML
assert " Reordering is not supported. " not in INDEX_HTML
assert " Reordering is not supported. " not in I18N_JS
assert " 不支持重新排序 " not in I18N_JS
def test_composer_control_order_is_validated_and_deduped ( ) :
""" The backend persists only known composer control order keys. """
import api . config as config
original_settings_file = config . SETTINGS_FILE
test_state_dir = ROOT / " .tmp-test-issue4598 "
try :
config . SETTINGS_FILE = test_state_dir / " settings.json "
test_state_dir . mkdir ( exist_ok = True )
saved = config . save_settings (
{
" composer_control_order " : [
" hide_composer_model " ,
" bogus " ,
" hide_composer_attach " ,
" hide_composer_model " ,
42 ,
" hide_composer_context " ,
]
}
)
assert saved [ " composer_control_order " ] == [
" hide_composer_model " ,
" hide_composer_attach " ,
" hide_composer_context " ,
]
persisted = json . loads ( config . SETTINGS_FILE . read_text ( encoding = " utf-8 " ) )
assert persisted [ " composer_control_order " ] == saved [ " composer_control_order " ]
finally :
config . SETTINGS_FILE = original_settings_file
shutil . rmtree ( test_state_dir , ignore_errors = True )
def test_composer_control_order_move_updates_both_chip_groups ( ) :
""" A same-footer-group drag saves order and re-renders both chip groups. """
node = shutil . which ( " node " )
if not node :
pytest . skip ( " node is required for the frontend behavior harness " )
helpers = " \n " . join (
_extract_function ( PANELS_JS , name )
for name in [
" _composerControlDefsForSettings " ,
" _getComposerControlOrder " ,
" _setComposerControlOrder " ,
" _orderedComposerControlDefsForSettings " ,
" _composerControlOrderGroupKey " ,
" _composerControlDropAllowed " ,
" _moveComposerControlOrderKey " ,
]
)
script = textwrap . dedent (
f """
global . window = { { } } ;
global . localStorage = { {
_items : { { } } ,
getItem ( key ) { { return this . _items [ key ] | | null ; } } ,
setItem ( key , value ) { { this . _items [ key ] = String ( value ) ; } } ,
} } ;
const _COMPOSER_CONTROL_ORDER_LS_KEY = ' hermes-webui-composer-control-order ' ;
window . _COMPOSER_CONTROL_TOGGLE_DEFS = [
{ { key : ' hide_composer_attach ' , orderGroup : ' left ' } } ,
{ { key : ' hide_composer_context ' , orderGroup : ' right ' } } ,
] ;
window . _COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS = [
{ { key : ' hide_composer_voice_mode ' , orderGroup : ' left ' } } ,
{ { key : ' hide_composer_status ' , orderGroup : ' right ' } } ,
] ;
window . _sanitizeComposerControlOrder = function ( order ) { {
const allowed = new Set ( window . _COMPOSER_CONTROL_TOGGLE_DEFS . concat ( window . _COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS ) . map ( def = > def . key ) ) ;
const out = [ ] ;
( Array . isArray ( order ) ? order : [ ] ) . forEach ( key = > { {
if ( allowed . has ( key ) & & ! out . includes ( key ) ) out . push ( key ) ;
} } ) ;
return out ;
} } ;
window . _applyComposerControlOrder = function ( order ) { { window . _appliedOrder = order . slice ( ) ; } } ;
let primaryRenders = 0 ;
let situationalRenders = 0 ;
let autosaves = 0 ;
function _renderComposerControlChips ( ) { { primaryRenders + = 1 ; } }
function _renderComposerSituationalControlChips ( ) { { situationalRenders + = 1 ; } }
function _scheduleAppearanceAutosave ( ) { { autosaves + = 1 ; } }
{ helpers }
if ( ! _moveComposerControlOrderKey ( ' hide_composer_voice_mode ' , ' hide_composer_attach ' ) ) throw new Error ( ' same-group move failed ' ) ;
if ( JSON . stringify ( window . _composerControlOrder ) != = JSON . stringify ( [ ' hide_composer_voice_mode ' , ' hide_composer_attach ' , ' hide_composer_context ' , ' hide_composer_status ' ] ) ) throw new Error ( ' unexpected order ' + JSON . stringify ( window . _composerControlOrder ) ) ;
if ( primaryRenders != = 1 | | situationalRenders != = 1 | | autosaves != = 1 ) throw new Error ( ' render/autosave counts wrong ' ) ;
if ( _moveComposerControlOrderKey ( ' hide_composer_status ' , ' hide_composer_attach ' ) ) throw new Error ( ' cross-group move should be rejected ' ) ;
"""
)
result = subprocess . run ( [ node , " -e " , script ] , cwd = ROOT , text = True , capture_output = True )
assert result . returncode == 0 , result . stderr