Zum Inhalt

tests/test_gui_endpoint_models.py

from __future__ import annotations

import re

from fastapi.testclient import TestClient

from touchstone.gui import app as gui_app
from touchstone.gui import configs as configs_mod
from touchstone.gui.control import RunRegistry


class _FakeLauncher:
    def spawn(self, argv):
        return 1

    def alive(self, pid):
        return False

    def terminate(self, pid):
        return None


def _client(tmp_path):
    reg = RunRegistry(runs_dir=tmp_path, launcher=_FakeLauncher())
    return TestClient(gui_app.create_app(runs_dir=tmp_path, registry=reg))


def test_config_default_is_not_embed(tmp_path):
    # the config <select> options follow order_configs (embed/vlm last) and the picker defaults
    # to configs[0]; assert the first config option is a non-embed/non-vlm config. (Decoupled
    # from JSON key order — no longer relies on a global tojson sort_keys policy.)
    body = _client(tmp_path).get("/config").text
    block = re.search(r'id="config_path".*?</select>', body, re.S)
    assert block is not None
    first = re.search(r'<option value="(config\.[^"]+\.yaml)"', block.group(0))
    assert first is not None
    assert "embed" not in first.group(1) and "vlm" not in first.group(1)


def test_config_page_has_single_model_select(tmp_path):
    body = _client(tmp_path).get("/config").text
    assert 'x-model="chosen"' in body  # the single-select bound to component state
    assert "fetchModelOptions" not in body  # JS lives in model_picker.js, not inline
    assert "endpointLoading" in body  # status state surfaced (spinner/text)
    # the three-way add machinery is gone — one obvious path
    assert "endpointPick" not in body
    assert "Vom Endpoint" not in body
    assert "Hinzufügen" not in body


def test_config_page_model_select_hidden_on_resume(tmp_path):
    body = _client(tmp_path).get("/config?resume=foo").text
    assert 'x-model="chosen"' not in body
    assert "modelPicker(" not in body


def test_config_page_model_select_tracks_user_interaction(tmp_path):
    # the model <select> must flag user interaction so a late (≤3s) endpoint fetch cannot
    # clobber a model the user already picked (default-adoption race).
    body = _client(tmp_path).get("/config").text
    assert "userChose" in body  # @change wires the interaction guard on the model select


def test_eval_model_options_route_merges_endpoint_and_config(tmp_path, monkeypatch):
    # config.m5.yaml declares qwen3.6-35b-a3b-4bit; endpoint serves "x" → merged single-select
    monkeypatch.setattr(
        configs_mod, "discover_endpoint_models", lambda config: {"models": ["x"], "error": None}
    )
    r = _client(tmp_path).get("/eval-model-options?config=config.m5.yaml")
    assert r.status_code == 200
    body = r.json()
    assert body["error"] is None
    assert body["default_id"] == "x"  # served model is the default, not the config placeholder
    ids = [(o["id"], o["served"]) for o in body["options"]]
    assert ("x", True) in ids
    assert ("qwen3.6-35b-a3b-4bit", False) in ids  # declared-but-not-served, flagged


def test_eval_model_options_route_error_is_200(tmp_path, monkeypatch):
    monkeypatch.setattr(
        configs_mod, "discover_endpoint_models", lambda config: {"models": [], "error": "down"}
    )
    r = _client(tmp_path).get("/eval-model-options?config=config.m5.yaml")
    assert r.status_code == 200  # offline endpoint is not a server error
    assert r.json()["error"] == "down"
    # offline → config models are the fallback options (default to the first one)
    assert r.json()["default_id"] == "qwen3.6-35b-a3b-4bit"


def test_eval_model_options_route_rejects_traversal(tmp_path):
    assert _client(tmp_path).get("/eval-model-options?config=../etc/passwd").status_code == 404


def test_eval_model_options_route_rejects_non_config_yaml(tmp_path):
    assert (
        _client(tmp_path).get("/eval-model-options?config=packs/ndassist.yaml").status_code == 404
    )


def test_judge_endpoint_models_guards_path_and_never_500(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    (tmp_path / "judge.yaml").write_text(
        "endpoint:\n  base_url: http://x/v1\nmodel: qwen\n", encoding="utf-8"
    )
    client = _client(tmp_path)

    # unknown / traversal path → 404
    assert client.get("/judge-endpoint-models?judge_config=/etc/passwd").status_code == 404
    # known judge config → 200, never 500 even if endpoint is dead
    r = client.get("/judge-endpoint-models?judge_config=judge.yaml")
    assert r.status_code == 200
    assert "models" in r.json() and "error" in r.json()