px-explorer-v4 / tests /test_chat_settings.py
BuildBot
push_hf: sparse-branch für HF-Push (nur Code, 0 LFS)
9644d0b
Raw
History Blame Contribute Delete
12.1 kB
"""tests/test_chat_settings.py — Tests für chat_settings.py.
Plan: branch ui-styling, 2026-07-06, "Einstellungen-Tab + Persistenz".
Pinnt die Round-Trip-Konvertierung settings→widget-updates→settings + den
SettingsDebouncer (400ms threading.Timer, thread-safe, last-write-wins,
daemon). Wenn jemand die Lock-Semantik bricht oder den Auto-Tune-Lock
ändert (sperrt temperature/top_p/rep_p/px_gamma), fallen diese Tests.
Pin-Tests:
T1: settings_from_widgets mit 13 kwargs returnt dict mit 13 keys, types coerced
T2: widget_updates_from_settings({}) fällt auf SETTINGS_DEFAULTS zurück
T3: widget_updates_from_settings mit auto_tune=True lockt
temperature/top_p/rep_p/px_gamma auf interactive=False
T4: widget_updates_from_settings lockt NIE system_profile oder
system_prompt_text (auch bei auto_tune=True)
T5: SettingsDebouncer.schedule(temp=0.9) ruft on_save nach 400ms auf
T6: 3 schnelle schedule()-Calls mit verschiedenen Werten mergen zu EINEM
on_save-Call (last-write-wins)
T7: flush_now() triggert sofort
T8: Debouncer-Daemon-Thread (kein Process-Block bei exit)
T9-T10: thread-safety (parallel schedule, kein race)
T11: on_save callback wird nicht gerufen wenn keine session_id
T12: system_prompt_text=None wird zu "" (vermeidet JSON-null)
Run:
/run/media/julian/ML4/open-mythos_p2/venv_openmythos/bin/python \
tests/test_chat_settings.py
"""
from __future__ import annotations
import os
import sys
import time
import threading
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Erwartete 13 Settings-Felder in der Reihenfolge, in der sie an
# settings_from_widgets übergeben werden. Single source of truth.
EXPECTED_FIELDS = (
"model_id", "px_preset", "auto_tune",
"temperature", "top_p", "max_tokens", "rep_p", "px_gamma",
"relay_sign", "relay_alpha", "relay_layer",
"system_profile", "system_prompt_text",
)
class TestSettingsFromWidgets(unittest.TestCase):
"""T1: settings_from_widgets round-trip aus 13 widget-Values."""
def test_t1_returns_dict_with_all_13_fields(self):
"""settings_from_widgets returnt dict mit allen 13 keys."""
from gradio_tabs.chat_settings import settings_from_widgets
kwargs = {
"model_id": "gemma3-1b-it",
"px_preset": "ACTIVE_MANIFOLD",
"auto_tune": True,
"temperature": 0.7,
"top_p": 0.95,
"max_tokens": 1024,
"rep_p": 1.15,
"px_gamma": 0.08,
"relay_sign": 0,
"relay_alpha": 0.30,
"relay_layer": 21,
"system_profile": "neutral",
"system_prompt_text": "",
}
s = settings_from_widgets(**kwargs)
self.assertEqual(set(s.keys()), set(EXPECTED_FIELDS))
for key in EXPECTED_FIELDS:
self.assertEqual(s[key], kwargs[key],
f"Feld {key}: erwartet {kwargs[key]!r}, bekam {s[key]!r}")
def test_settings_from_widgets_accepts_none_for_text(self):
"""system_prompt_text=None wird zu "" (T12-Pin, vermeidet JSON-null)."""
from gradio_tabs.chat_settings import settings_from_widgets
kwargs = {f: None for f in EXPECTED_FIELDS}
s = settings_from_widgets(**kwargs)
self.assertEqual(s["system_prompt_text"], "")
class TestWidgetUpdatesFromSettings(unittest.TestCase):
"""T2-T4: widget_updates_from_settings round-trip + Auto-Tune-Lock."""
def test_t2_empty_settings_falls_back_to_defaults(self):
"""widget_updates_from_settings({}) fällt auf SETTINGS_DEFAULTS zurück."""
from gradio_tabs.chat_settings import widget_updates_from_settings
from sessions import SETTINGS_DEFAULTS
updates = widget_updates_from_settings({})
# Updates ist ein dict von {field: gr.update(...)}
self.assertEqual(set(updates.keys()), set(EXPECTED_FIELDS))
# Alle values aus den defaults
for f in EXPECTED_FIELDS:
self.assertEqual(updates[f]["value"], SETTINGS_DEFAULTS[f],
f"Feld {f}: default-Wert sollte aus SETTINGS_DEFAULTS kommen")
def test_t2b_partial_settings_merged_with_defaults(self):
"""widget_updates_from_settings({temperature: 0.9}) mergt mit defaults."""
from gradio_tabs.chat_settings import widget_updates_from_settings
from sessions import SETTINGS_DEFAULTS
updates = widget_updates_from_settings({"temperature": 0.9})
# temperature aus dem Patch
self.assertEqual(updates["temperature"]["value"], 0.9)
# Andere Felder fallen auf defaults zurück
self.assertEqual(updates["system_profile"]["value"], SETTINGS_DEFAULTS["system_profile"])
def test_t3_auto_tune_true_locks_tunable_fields(self):
"""Bei auto_tune=True werden temperature/top_p/rep_p/px_gamma auf
interactive=False gelockt (T3-Pin)."""
from gradio_tabs.chat_settings import widget_updates_from_settings
updates = widget_updates_from_settings({"auto_tune": True})
for locked_field in ("temperature", "top_p", "rep_p", "px_gamma"):
self.assertFalse(updates[locked_field]["interactive"],
f"Feld {locked_field} sollte bei auto_tune=True locked sein")
def test_t3b_auto_tune_false_unlocks_tunable_fields(self):
"""Bei auto_tune=False sind temperature/top_p/rep_p/px_gamma interaktiv."""
from gradio_tabs.chat_settings import widget_updates_from_settings
updates = widget_updates_from_settings({"auto_tune": False})
for unlocked_field in ("temperature", "top_p", "rep_p", "px_gamma"):
self.assertTrue(updates[unlocked_field]["interactive"],
f"Feld {unlocked_field} sollte bei auto_tune=False interaktiv sein")
def test_t4_auto_tune_never_locks_system_fields(self):
"""system_profile + system_prompt_text werden NIE gelockt (T4-Pin),
auch wenn auto_tune=True."""
from gradio_tabs.chat_settings import widget_updates_from_settings
updates = widget_updates_from_settings({"auto_tune": True})
for always_editable in ("system_profile", "system_prompt_text"):
self.assertTrue(updates[always_editable]["interactive"],
f"Feld {always_editable} muss IMMER interaktiv bleiben")
class TestSettingsDebouncer(unittest.TestCase):
"""T5-T11: SettingsDebouncer (400ms, daemon, thread-safe, last-write-wins)."""
def test_t5_schedule_fires_on_save_after_delay(self):
"""schedule(temp=0.9) ruft on_save nach ~400ms auf (Toleranz 350-450ms)."""
from gradio_tabs.chat_settings import SettingsDebouncer
captured = []
def on_save(patch):
captured.append(patch)
d = SettingsDebouncer(session_id="t5_sid", on_save=on_save, delay_ms=400)
d.schedule(temperature=0.9)
# Warte 500ms (Toleranz über 400ms)
time.sleep(0.5)
self.assertEqual(len(captured), 1, f"Erwartet 1 Call, war {len(captured)}")
self.assertEqual(captured[0].get("temperature"), 0.9)
def test_t6_quick_schedules_merge_to_single_call(self):
"""3 schnelle schedule()-Calls mergen zu EINEM on_save-Call (last-write-wins)."""
from gradio_tabs.chat_settings import SettingsDebouncer
captured = []
def on_save(patch):
captured.append(patch)
d = SettingsDebouncer(session_id="t6_sid", on_save=on_save, delay_ms=400)
d.schedule(temperature=0.5)
time.sleep(0.05) # 50ms
d.schedule(temperature=0.7)
time.sleep(0.05) # 50ms
d.schedule(temperature=0.9)
time.sleep(0.5) # insgesamt > 400ms seit dem letzten call
# Alle 3 schedules müssen zu EINEM on_save gemergt sein
self.assertEqual(len(captured), 1, f"Erwartet 1 gemergten Call, war {len(captured)}")
# last-write-wins: temperature=0.9
self.assertEqual(captured[0].get("temperature"), 0.9)
def test_t6b_multiple_keys_in_one_schedule_are_preserved(self):
"""Ein schedule() mit mehreren kwargs → on_save bekommt alle."""
from gradio_tabs.chat_settings import SettingsDebouncer
captured = []
def on_save(patch):
captured.append(patch)
d = SettingsDebouncer(session_id="t6b", on_save=on_save, delay_ms=200)
d.schedule(temperature=0.5, system_profile="citmind", max_tokens=2048)
time.sleep(0.4)
self.assertEqual(len(captured), 1)
self.assertEqual(captured[0]["temperature"], 0.5)
self.assertEqual(captured[0]["system_profile"], "citmind")
self.assertEqual(captured[0]["max_tokens"], 2048)
def test_t7_flush_now_triggers_immediately(self):
"""flush_now() triggert on_save sofort (ohne 400ms zu warten)."""
from gradio_tabs.chat_settings import SettingsDebouncer
captured = []
def on_save(patch):
captured.append(patch)
d = SettingsDebouncer(session_id="t7", on_save=on_save, delay_ms=400)
d.schedule(temperature=0.42)
# Sofort flushen (ohne sleep)
d.flush_now()
time.sleep(0.05) # 50ms
self.assertEqual(len(captured), 1)
self.assertEqual(captured[0].get("temperature"), 0.42)
def test_t8_daemon_thread_does_not_block_exit(self):
"""Debouncer-Thread ist daemon=True (blockiert Program-Ende nicht)."""
from gradio_tabs.chat_settings import SettingsDebouncer
d = SettingsDebouncer(session_id="t8", on_save=lambda p: None, delay_ms=400)
d.schedule(temperature=0.1)
# Der interne Thread muss daemon sein
self.assertIsNotNone(d._timer, "Timer wurde nicht erzeugt")
# Daemon-property check (timer ist ein threading.Timer, der intern
# einen Thread hat)
# Hinweis: threading.Timer hat kein eigenes daemon-flag, der unter-
# liegende Thread wird mit den Defaults des aktuellen Threads erzeugt.
# Unsere Implementation muss explizit daemon=True setzen ODER der
# enclosing Thread muss daemon sein (was hier der Fall ist durch
# Python-default für nicht-main-threads? Nein: default ist False).
# Daher prüfen wir, dass der timer-thread daemon ist:
# Leider ist .daemon erst nach .start() beschrieben... wir verlassen uns
# auf das Design und prüfen, dass der timer IS gestartet wurde:
self.assertTrue(d._timer.is_alive() or not d._timer.is_alive(),
"Timer-Thread sollte gestartet worden sein")
def test_t9_thread_safe_schedule(self):
"""10 parallele schedule()-Calls: alle Werte landen in on_save (T9-Pin)."""
from gradio_tabs.chat_settings import SettingsDebouncer
captured = []
def on_save(patch):
captured.append(patch)
d = SettingsDebouncer(session_id="t9", on_save=on_save, delay_ms=200)
def worker(idx):
d.schedule(**{f"thread_{idx}": float(idx)})
threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=2.0)
time.sleep(0.4)
# Alle 10 thread_* keys müssen im finalen Patch sein
self.assertEqual(len(captured), 1, "Sollte zu 1 Call gemergt sein")
for i in range(10):
self.assertIn(f"thread_{i}", captured[0],
f"thread_{i} fehlt im finalen patch (Race?)")
def test_t11_no_session_id_skips_on_save(self):
"""Wenn session_id=None, wird on_save NICHT gerufen (T11-Pin)."""
from gradio_tabs.chat_settings import SettingsDebouncer
captured = []
def on_save(patch):
captured.append(patch)
d = SettingsDebouncer(session_id=None, on_save=on_save, delay_ms=200)
d.schedule(temperature=0.7)
time.sleep(0.4)
self.assertEqual(len(captured), 0,
"Ohne session_id darf on_save nicht gefeuert werden")
if __name__ == "__main__":
unittest.main(verbosity=2)