Zum Inhalt

tests/test_config.py

import pytest
from pydantic import ValidationError

from touchstone.config import Config, load_config


def _base(**kw):
    d = {
        "endpoint": {"base_url": "http://localhost:1234/v1"},
        "machine": "M1-16GB",
        "models": [{"id": "qwen3-8b", "quant": "Q5_K_M"}],
    }
    d.update(kw)
    return d


def test_load_valid_config_file(tmp_path):
    p = tmp_path / "c.yaml"
    p.write_text(
        "endpoint:\n  base_url: http://localhost:1234/v1\n"
        "machine: M1\nmodels:\n  - {id: qwen3-8b, quant: Q5}\n",
        encoding="utf-8",
    )
    cfg = load_config(p)
    assert cfg.machine == "M1"
    assert cfg.endpoint.api_key == "not-needed"
    assert cfg.models[0].id == "qwen3-8b"


def test_unknown_scenario_rejected():
    with pytest.raises(ValidationError):
        Config.model_validate(_base(scenarios=["bodydouble", "bogus"]))


def test_empty_models_rejected():
    with pytest.raises(ValidationError):
        Config.model_validate(_base(models=[]))


def test_runs_per_cell_minimum():
    with pytest.raises(ValidationError):
        Config.model_validate(_base(runs_per_cell=1))


def test_context_buckets_must_not_be_empty():
    with pytest.raises(ValidationError):
        Config.model_validate(_base(context_buckets=[]))


def test_context_buckets_must_be_positive():
    with pytest.raises(ValidationError):
        Config.model_validate(_base(context_buckets=[4096, 0, -5]))


def test_max_tokens_resolution_order():
    cfg = Config.model_validate(_base(max_tokens={"bodydouble": 99}))
    model = cfg.models[0]
    assert cfg.max_tokens_for("bodydouble", model) == 99  # explicit override
    assert cfg.max_tokens_for("compose", model) == 400  # scenario default
    # scenario with no default falls back to model default
    model.max_tokens_default = 555
    assert cfg.max_tokens_for("unlisted", model) == 555


def test_shipped_configs_parse():
    # EVERY shipped config must validate — globbed, not enumerated. A hand-kept list silently
    # exempts each newly added config: config.embed/gemma-thinking/m5-lmstudio/m5.vlm/ndeval
    # were all outside the old three-name list.
    from pathlib import Path

    root = Path(__file__).resolve().parent.parent
    shipped = sorted(root.glob("config*.yaml"))
    # A glob that matches nothing is green and blind — pin a floor, and keep naming the configs
    # that must always exist, so a rename can't hollow the test out unnoticed.
    assert len(shipped) >= 3, f"no shipped configs globbed: {shipped}"
    names = {p.name for p in shipped}
    assert {"config.example.yaml", "config.m1.yaml", "config.m5.yaml"} <= names, names

    for path in shipped:
        cfg = load_config(path)
        assert cfg.machine, f"{path.name} has no machine label"


def test_modelspec_thinking_defaults_are_neutral():
    from touchstone.config import ModelSpec

    m = ModelSpec(id="x")
    assert m.reasoning_headroom_tokens == 0
    assert m.extra_body == {}


def test_modelspec_thinking_fields_roundtrip_through_models_json():
    from touchstone.config import models_from_json

    specs = models_from_json(
        '[{"id": "gemma", "reasoning_headroom_tokens": 2000,'
        ' "extra_body": {"chat_template_kwargs": {"enable_thinking": false}}}]'
    )
    assert specs[0].reasoning_headroom_tokens == 2000
    assert specs[0].extra_body == {"chat_template_kwargs": {"enable_thinking": False}}