db31bc93f7
Der Bereich war sichtbar, wurde aber übersehen: ein 12-Pixel-Knopf, der an der Überschrift klebte, während "Handel pausieren" und "Training starten" jeweils in einer eigenen Box mit normalgroßem Knopf sitzen. 71x26 px gegenüber 90x58 und 79x58 - das liest sich nicht als Bedienelement. Der Bereich sieht jetzt aus wie die beiden anderen: eigene Panel-Box, Knopf "Konfiguration bearbeiten" in gleicher Größe (114x58 px, 14px Schrift), daneben eine Zeile, die sagt, was dahintersteckt. Geöffnet heißt der Knopf "Konfiguration schließen". Zwei neue Tests halten die drei Haupt-Bedienelemente auf gleicher Linie: keine Inline-Styles, die Schrift oder Polsterung verkleinern, und jeder Knopf muss in einer Panel-Box stehen statt in einer Überschrift. 233 Tests (6 neue), ruff sauber. Im Browser durchgespielt: öffnen, 76 Felder, risk.cooldown_bars_after_exit geändert und sofort wirksam, schließen.
208 lines
7.6 KiB
Python
208 lines
7.6 KiB
Python
r"""Das eingebettete Dashboard-Skript muss syntaktisch heil beim Browser ankommen.
|
||
|
||
Hintergrund: ``_DASHBOARD`` ist ein Python-String. Fehlt das r-Präfix, macht Python aus
|
||
einem ``\n``, das eigentlich dem JavaScript gehört, einen echten Zeilenumbruch. Das offene
|
||
String-Literal reißt dann das gesamte ``<script>`` mit – die Seite lädt, zeigt aber nur
|
||
"lädt …", weil kein einziger Ausdruck ausgeführt wird. Genau das ist schon passiert.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
|
||
import pytest
|
||
|
||
from trademind.server import _DASHBOARD
|
||
|
||
SCRIPT_RE = re.compile(r"<script>(.*?)</script>", re.DOTALL)
|
||
|
||
|
||
def script_body() -> str:
|
||
match = SCRIPT_RE.search(_DASHBOARD)
|
||
assert match is not None, "Das Dashboard enthält keinen <script>-Block"
|
||
return match.group(1)
|
||
|
||
|
||
def unterminated_string(js: str) -> str | None:
|
||
"""Findet ein String-Literal, das vor dem Zeilenende nicht geschlossen wird.
|
||
|
||
Nur Template-Literale (Backticks) dürfen über mehrere Zeilen gehen. Gibt eine
|
||
Beschreibung der Fundstelle zurück oder ``None``, wenn alles sauber ist.
|
||
"""
|
||
state: str | None = None # None | " | ' | ` | // | /*
|
||
opened_at = 0
|
||
line = 1
|
||
i = 0
|
||
while i < len(js):
|
||
char = js[i]
|
||
nxt = js[i + 1 : i + 2]
|
||
if state is None:
|
||
if char == "/" and nxt == "/":
|
||
state, i = "//", i + 1
|
||
elif char == "/" and nxt == "*":
|
||
state, i = "/*", i + 1
|
||
elif char in "\"'`":
|
||
state, opened_at = char, line
|
||
elif char == "\n":
|
||
line += 1
|
||
elif state == "//":
|
||
if char == "\n":
|
||
state, line = None, line + 1
|
||
elif state == "/*":
|
||
if char == "*" and nxt == "/":
|
||
state, i = None, i + 1
|
||
elif char == "\n":
|
||
line += 1
|
||
else: # innerhalb eines String-Literals
|
||
if char == "\\":
|
||
i += 1 # nächstes Zeichen ist escaped
|
||
elif char == state:
|
||
state = None
|
||
elif char == "\n":
|
||
if state != "`":
|
||
return f"String mit {state} in Zeile {opened_at} endet nicht auf derselben Zeile"
|
||
line += 1
|
||
i += 1
|
||
|
||
if state in ("\"", "'", "`"):
|
||
return f"String mit {state} ab Zeile {opened_at} wird nie geschlossen"
|
||
return None
|
||
|
||
|
||
def test_script_has_no_unterminated_string_literal():
|
||
assert unterminated_string(script_body()) is None
|
||
|
||
|
||
def test_javascript_escapes_survive_python():
|
||
r"""Ein \n im Quelltext muss als zwei Zeichen beim Browser ankommen."""
|
||
assert "\\n" in _DASHBOARD, "Escape-Sequenz wurde von Python aufgelöst – r-String fehlt?"
|
||
|
||
|
||
def test_braces_are_balanced():
|
||
js = script_body()
|
||
depth = 0
|
||
for char in js:
|
||
depth += char == "{"
|
||
depth -= char == "}"
|
||
assert depth >= 0, "Mehr schließende als öffnende Klammern"
|
||
assert depth == 0, f"Klammern unausgeglichen (Differenz {depth})"
|
||
|
||
|
||
def test_every_referenced_element_exists():
|
||
"""Die Skript-Initialisierung greift auf feste IDs zu – fehlt eine, bricht alles ab."""
|
||
js = script_body()
|
||
referenced = set(re.findall(r"""\$\(["']([\w-]+)["']\)""", js))
|
||
referenced |= set(re.findall(r"""getElementById\(["']([\w-]+)["']\)""", js))
|
||
assert referenced, "Keine Element-Referenzen gefunden – Test greift ins Leere"
|
||
for element_id in sorted(referenced):
|
||
assert f'id="{element_id}"' in _DASHBOARD, f"Element #{element_id} fehlt im HTML"
|
||
|
||
|
||
def function_body(js: str, name: str) -> str:
|
||
"""Rumpf einer Funktion per Klammerzählung ausschneiden."""
|
||
start = js.index(f"function {name}(")
|
||
open_brace = js.index("{", start)
|
||
depth = 0
|
||
for i in range(open_brace, len(js)):
|
||
depth += js[i] == "{"
|
||
depth -= js[i] == "}"
|
||
if depth == 0:
|
||
return js[open_brace : i + 1]
|
||
raise AssertionError(f"Funktion {name} ist nicht geschlossen")
|
||
|
||
|
||
def hidden_section_ids() -> list[str]:
|
||
return re.findall(r'<section id="([\w-]+)"[^>]*\shidden', _DASHBOARD)
|
||
|
||
|
||
def status_loop_code() -> str:
|
||
"""Der Code, der bei jedem Statuslauf durchläuft: refresh und die daraus gerufenen
|
||
render-Funktionen. Nur was hier steht, kann einen Abschnitt von selbst einblenden."""
|
||
js = script_body()
|
||
body = function_body(js, "refresh")
|
||
code = [body]
|
||
for name in sorted(set(re.findall(r"\b(render\w+)\(", body))):
|
||
code.append(function_body(js, name))
|
||
return "\n".join(code)
|
||
|
||
|
||
def test_there_are_hidden_sections_to_check():
|
||
assert hidden_section_ids(), "Test greift ins Leere – keine versteckten Abschnitte gefunden"
|
||
|
||
|
||
@pytest.mark.parametrize("section_id", hidden_section_ids())
|
||
def test_hidden_sections_are_unhidden_by_the_status_loop(section_id: str):
|
||
"""Ein Abschnitt, dessen Aufklapp-Knopf in ihm selbst sitzt, blendet sich sonst nie ein.
|
||
|
||
Genau das ist passiert: Der Konfigurationsbereich war für Benutzer unerreichbar, weil
|
||
nur ein Klick darin ihn sichtbar gemacht hätte. Sichtbarkeit gehört deshalb in
|
||
``refresh()``, das bei jedem Statuslauf durchläuft.
|
||
"""
|
||
code = status_loop_code()
|
||
assert f'$("{section_id}").hidden' in code, (
|
||
f"#{section_id} startet versteckt und wird im Statuslauf nicht sichtbar gemacht – "
|
||
"ein Knopf innerhalb des Abschnitts kann ihn nicht einblenden"
|
||
)
|
||
|
||
|
||
PRIMARY_BUTTONS = ["trade-btn", "train-btn", "cfg-toggle"]
|
||
|
||
|
||
@pytest.mark.parametrize("button_id", PRIMARY_BUTTONS)
|
||
def test_primary_buttons_are_not_shrunk_by_inline_styles(button_id: str):
|
||
"""Die Haupt-Bedienelemente sollen gleich aussehen.
|
||
|
||
Der Konfigurationsknopf war einmal ein 12-Pixel-Anhängsel an einer Überschrift und
|
||
wurde schlicht übersehen. Inline-Styles, die Schrift oder Polsterung verkleinern,
|
||
gehören hier nicht hin.
|
||
"""
|
||
match = re.search(rf'<button id="{button_id}"([^>]*)>', _DASHBOARD)
|
||
assert match is not None, f"Knopf #{button_id} fehlt"
|
||
attributes = match.group(1)
|
||
for verboten in ("font-size", "padding"):
|
||
assert verboten not in attributes, (
|
||
f"#{button_id} verkleinert sich per Inline-Style ({verboten}) gegenüber den anderen"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("button_id", PRIMARY_BUTTONS)
|
||
def test_primary_buttons_sit_inside_a_panel(button_id: str):
|
||
"""Ein Knopf direkt in der Überschrift liest sich nicht als Bedienelement."""
|
||
match = re.search(rf'<button id="{button_id}"[^>]*>', _DASHBOARD)
|
||
before = _DASHBOARD[: match.start()]
|
||
letzte_ueberschrift = before.rfind("<h2>")
|
||
letztes_panel = before.rfind('class="panel"')
|
||
assert letztes_panel > letzte_ueberschrift, (
|
||
f"#{button_id} steht in einer Überschrift statt in einer Panel-Box"
|
||
)
|
||
|
||
|
||
def test_render_functions_are_called_by_the_status_loop():
|
||
body = function_body(script_body(), "refresh")
|
||
for name in ("renderTrading", "renderTraining"):
|
||
assert f"{name}(" in body, f"{name} wird nie aufgerufen"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"needle",
|
||
[
|
||
"Automatisierter Handel",
|
||
"Training starten",
|
||
"Kontinuierliches Lernen",
|
||
"control/trading",
|
||
"control/train/history",
|
||
"control/train/live",
|
||
"X-TradeMind-Token",
|
||
],
|
||
)
|
||
def test_dashboard_wires_up_the_controls(needle: str):
|
||
assert needle in _DASHBOARD
|
||
|
||
|
||
def test_broken_example_is_detected():
|
||
"""Gegenprobe: Der Prüfer muss den ursprünglichen Fehler erkennen."""
|
||
broken = 'const a = prompt("erste Zeile\nzweite Zeile");'
|
||
assert unterminated_string(broken) is not None
|
||
assert unterminated_string('const a = prompt("erste Zeile\\nzweite Zeile");') is None
|
||
assert unterminated_string("const a = `mehrere\nZeilen sind hier erlaubt`;") is None
|