Files
hermes-webui/tests/test_issue4598_composer_control_visibility.py
Paladin173 d8f0f8f97f Fix composer control reorder rebase collision
Reapply footer control ordering on current upstream/master while preserving the required situational chip renderer. Persist composer_control_order with backend validation, keep the settings descriptions reorder-aware, and make primary/situational chip renderers participate in same-group drag ordering.

Verified with: node --check static/boot.js; node --check static/panels.js; git diff --check; ./scripts/test.sh tests/test_issue4598_composer_control_visibility.py
2026-06-30 21:11:54 -05:00

250 lines
10 KiB
Python

"""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.
"""
import json
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
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")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
# 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",
]
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}")
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
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
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"
)
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