# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

"""Unit tests for the llama-server pass-through args validator.

The validator is the boundary between user CLI/HTTP input and the
llama-server subprocess. These tests pin denylist behaviour so it doesn't
regress when new managed flags are added.
"""

from __future__ import annotations

import importlib.util
import re
from pathlib import Path

import pytest

# Load llama_server_args.py directly to avoid dragging in the full backend
# chain via core/inference/__init__.py. The validator is dependency-free.
_LSA_PATH = Path(__file__).resolve().parent.parent / "core" / "inference" / "llama_server_args.py"
_spec = importlib.util.spec_from_file_location("_lsa_test_only", _LSA_PATH)
_lsa = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_lsa)
is_managed_flag = _lsa.is_managed_flag
parse_cache_override = _lsa.parse_cache_override
parse_cache_override_per_axis = _lsa.parse_cache_override_per_axis
parse_ctx_override = _lsa.parse_ctx_override
parse_split_mode_override = _lsa.parse_split_mode_override
resolve_cache_type_kv = _lsa.resolve_cache_type_kv
resolve_tensor_parallel = _lsa.resolve_tensor_parallel
strip_shadowing_flags = _lsa.strip_shadowing_flags
strip_split_mode_only = _lsa.strip_split_mode_only
extra_args_disable_mmproj = _lsa.extra_args_disable_mmproj
validate_extra_args = _lsa.validate_extra_args


# ── Pass-through (allowed) ───────────────────────────────────────────


@pytest.mark.parametrize(
    "args",
    [
        # Sampling
        ["--top-k", "20"],
        ["--top-p", "0.9", "--min-p", "0.05"],
        ["--seed", "-1"],  # negative value, not a flag
        ["--temp", "0.0"],
        ["--repeat-penalty", "1.05"],
        ["--mirostat", "2", "--mirostat-lr", "0.1"],
        ["--xtc-probability", "0.05", "--xtc-threshold", "0.1"],
        ["--dry-multiplier", "0.5"],
        # Tier-2 knobs that map to LoadRequest fields
        ["--cache-type-k", "q8_0"],
        ["--cache-type-v", "q8_0"],
        ["--chat-template-file", "/tmp/tpl.jinja"],
        ["--chat-template-kwargs", '{"reasoning_effort":"high"}'],
        ["--spec-type", "ngram-mod"],
        ["--spec-default"],
        # MTP path (llama.cpp #22673).
        ["--spec-type", "draft-mtp"],
        ["--spec-type", "draft-mtp", "--spec-draft-n-max", "6"],
        [
            "--spec-type",
            "ngram-mod,draft-mtp",
            "--spec-draft-n-max",
            "3",
            "--spec-ngram-mod-n-match",
            "24",
            "--spec-ngram-mod-n-min",
            "48",
            "--spec-ngram-mod-n-max",
            "64",
        ],
        # Reasoning controls
        ["--reasoning-format", "deepseek"],
        ["-rea", "auto"],
        # Soft-managed: user flags last-wins over Studio's auto-set version.
        # --parallel / -np / --n-parallel are hard-denied (KV-cache + slot
        # count would desync); use `unsloth studio run --parallel N` instead.
        ["-c", "131072"],
        ["--ctx-size", "8192"],
        ["--flash-attn", "off"],
        ["-fa", "on"],
        ["--no-context-shift"],
        ["--context-shift"],
        ["--jinja"],
        ["--no-jinja"],
        ["-ngl", "-1"],
        ["--gpu-layers", "32"],
        ["-t", "16"],
        ["--threads", "32"],
        ["-fit", "off"],
        ["--fit", "on"],
        ["--fit-ctx", "8192"],
    ],
)
def test_pass_through_allowed(args):
    assert validate_extra_args(args) == args


def test_none_returns_empty_list():
    assert validate_extra_args(None) == []


def test_empty_list_returns_empty_list():
    assert validate_extra_args([]) == []


def test_value_with_equals_form_passes_through():
    assert validate_extra_args(["--top-k=20"]) == ["--top-k=20"]


def test_non_flag_token_passes_through():
    # Bare positionals are passed through; llama-server can reject them.
    assert validate_extra_args(["foo"]) == ["foo"]


# ── Denylist (rejected) ──────────────────────────────────────────────


@pytest.mark.parametrize(
    "denied",
    [
        # Parallel slots -- owned by the typer --parallel flag.
        "-np",
        "--parallel",
        "--n-parallel",
        # Model identity (every alias; bumping llama.cpp must keep every
        # form rejected, not just the long one).
        "-m",
        "--model",
        "-mu",
        "--model-url",
        "-dr",
        "--docker-repo",
        "-hf",
        "-hfr",
        "--hf-repo",
        "-hff",
        "--hf-file",
        "-hfv",
        "-hfrv",
        "--hf-repo-v",
        "-hffv",
        "--hf-file-v",
        "-hft",
        "--hf-token",
        "-mm",
        "--mmproj",
        "-mmu",
        "--mmproj-url",
        # Networking (Studio binds + proxies)
        "--host",
        "--port",
        "--path",
        "--api-prefix",
        "--reuse-port",
        # Auth / TLS
        "--api-key",
        "--api-key-file",
        "--ssl-key-file",
        "--ssl-cert-file",
        # Single-model server (legacy --webui + current --ui group)
        "--webui",
        "--no-webui",
        "--ui",
        "--no-ui",
        "--ui-config",
        "--ui-config-file",
        "--ui-mcp-proxy",
        "--no-ui-mcp-proxy",
        "--models-dir",
        "--models-preset",
        "--models-max",
        "--models-autoload",
        "--no-models-autoload",
        # Server-mode flips: --embedding / --rerank restrict llama-server to
        # those endpoints and break Studio's chat hop.
        "--embedding",
        "--embeddings",
        "--rerank",
        "--reranking",
        # llama-server's own --tools clashes with Studio's tool policy.
        "--tools",
    ],
)
def test_denylist_rejects_all_aliases(denied):
    with pytest.raises(ValueError, match = denied):
        validate_extra_args([denied, "value"])


@pytest.mark.parametrize(
    "args,offending",
    [
        # Pass-through --parallel would last-wins-override the real slot
        # count while Studio's KV-cache fit + llama_parallel_slots stay at
        # the typer value -- plan vs. process disagree.
        (["--parallel", "8"], "--parallel"),
        (["--parallel=8"], "--parallel"),
        (["--n-parallel", "16"], "--n-parallel"),
        (["--n-parallel=16"], "--n-parallel"),
        (["-np", "32"], "-np"),
        # Attached short form: Click clusters it CLI-side; HTTP /load with
        # `["-np8"]` must still resolve to managed.
        (["-np8"], "-np"),
        (["-np64"], "-np"),
        # Out-of-range values that would bypass the typer 1..64 guard.
        (["--parallel", "999"], "--parallel"),
        (["-np", "0"], "-np"),
        (["-np999"], "-np"),
        # Signed attached forms; `-np-1` must not slip past.
        (["-np-1"], "-np"),
        (["-np+1"], "-np"),
    ],
)
def test_parallel_flags_are_managed(args, offending):
    with pytest.raises(ValueError, match = re.escape(offending)):
        validate_extra_args(args)


def test_denylist_rejects_equals_form():
    with pytest.raises(ValueError, match = "--port"):
        validate_extra_args(["--port=9000"])


@pytest.mark.parametrize(
    "padded",
    [" --parallel", "--parallel ", "\t--parallel", "  -np", "-np \n", "-np\t"],
)
def test_denylist_rejects_whitespace_padded_forms(padded):
    # `_flag_name` trims whitespace before lookup; else a trailing space
    # could slip a managed flag past the boundary.
    with pytest.raises(ValueError, match = "parallel|np"):
        validate_extra_args([padded, "8"])


@pytest.mark.parametrize(
    "attached",
    ["-np8x", "-np-1foo", "-np+1bar", "-np9zzz"],
)
def test_denylist_rejects_np_with_digit_prefix_and_junk(attached):
    # Backend `_flag_name` must classify the same forms the CLI rewriter
    # expands, else HTTP /load could smuggle `-np8x` through.
    with pytest.raises(ValueError, match = "np"):
        validate_extra_args([attached])


def test_denylist_rejects_short_form_when_long_is_denied():
    # `-m` is the short form of --model; rejecting only the long form
    # would leave a trivial bypass.
    with pytest.raises(ValueError, match = "-m"):
        validate_extra_args(["-m", "/some/other/path.gguf"])


def test_denylist_message_names_offending_flag():
    with pytest.raises(ValueError) as excinfo:
        validate_extra_args(["--top-k", "20", "--api-key", "secret"])
    assert "--api-key" in str(excinfo.value)


def test_first_denied_flag_short_circuits():
    # Validation stops at the first denied flag; the message names it.
    with pytest.raises(ValueError, match = "--port"):
        validate_extra_args(["--port", "1", "--host", "x"])


# ── Numeric values that look flag-ish ─────────────────────────────────


@pytest.mark.parametrize("value", ["-1", "-0.5", "-42", "-.5"])
def test_negative_number_value_is_not_flag(value):
    # `--seed -1`: the -1 is a value, not a flag.
    assert validate_extra_args(["--seed", value]) == ["--seed", value]


# ── is_managed_flag helper ───────────────────────────────────────────


def test_is_managed_flag_true_for_denied():
    assert is_managed_flag("--port") is True
    assert is_managed_flag("--api-key") is True
    assert is_managed_flag("-m") is True
    assert is_managed_flag("--model") is True
    # Parallel slots owned by the typer --parallel flag.
    assert is_managed_flag("--parallel") is True
    assert is_managed_flag("--n-parallel") is True
    assert is_managed_flag("-np") is True
    # Normalised forms must classify like the canonical token so
    # is_managed_flag filtering stays in sync with validate_extra_args.
    assert is_managed_flag("-np8") is True
    assert is_managed_flag("--parallel=8") is True
    assert is_managed_flag("--port=9000") is True


def test_is_managed_flag_false_for_pass_through():
    assert is_managed_flag("--top-k") is False
    assert is_managed_flag("--cache-type-k") is False
    assert is_managed_flag("--chat-template-file") is False
    # Soft-managed flags pass through (last-wins override)
    assert is_managed_flag("-c") is False
    assert is_managed_flag("--ctx-size") is False
    assert is_managed_flag("--flash-attn") is False
    assert is_managed_flag("-ngl") is False
    assert is_managed_flag("--threads") is False


# ── strip_shadowing_flags ─────────────────────────────────────────────


def test_strip_shadowing_flags_drops_context_when_requested():
    out = strip_shadowing_flags(
        ["-c", "4096", "--top-k", "20"],
        strip_context = True,
        strip_cache = False,
        strip_spec = False,
        strip_template = False,
    )
    assert out == ["--top-k", "20"]


def test_strip_shadowing_flags_keeps_context_when_not_requested():
    out = strip_shadowing_flags(
        ["-c", "4096", "--top-k", "20"],
        strip_context = False,
        strip_cache = False,
        strip_spec = False,
        strip_template = False,
    )
    assert out == ["-c", "4096", "--top-k", "20"]


def test_strip_shadowing_flags_keeps_chat_template_when_template_disabled():
    # No chat_template_override supplied; inherited
    # --chat-template-file must survive.
    out = strip_shadowing_flags(
        ["--chat-template-file", "/tmp/custom.jinja", "--top-k", "20"],
        strip_context = True,
        strip_cache = True,
        strip_spec = True,
        strip_template = False,
    )
    assert out == ["--chat-template-file", "/tmp/custom.jinja", "--top-k", "20"]


def test_strip_shadowing_flags_drops_template_when_requested():
    out = strip_shadowing_flags(
        ["--chat-template-file", "/tmp/custom.jinja", "--top-k", "20"],
        strip_template = True,
    )
    assert out == ["--top-k", "20"]


def test_strip_shadowing_flags_keeps_cache_when_cache_disabled():
    out = strip_shadowing_flags(
        ["--cache-type-k", "q8_0", "--cache-type-v", "q8_0", "--top-k", "20"],
        strip_cache = False,
    )
    assert out == ["--cache-type-k", "q8_0", "--cache-type-v", "q8_0", "--top-k", "20"]


def test_strip_shadowing_flags_keeps_spec_when_spec_disabled():
    out = strip_shadowing_flags(
        ["--spec-type", "ngram-mod", "--draft-min", "48", "--top-k", "20"],
        strip_spec = False,
    )
    assert out == ["--spec-type", "ngram-mod", "--draft-min", "48", "--top-k", "20"]


def test_strip_shadowing_flags_drops_mtp_flags_when_requested():
    # MTP / draft-mtp flags must drop when speculative_type re-applies.
    out = strip_shadowing_flags(
        [
            "--spec-type",
            "draft-mtp",
            "--spec-draft-n-max",
            "6",
            "--spec-ngram-mod-n-match",
            "24",
            "--spec-ngram-mod-n-min",
            "48",
            "--spec-ngram-mod-n-max",
            "6",
            "--top-k",
            "20",
        ],
        strip_spec = True,
    )
    assert out == ["--top-k", "20"]


def test_is_managed_flag_false_for_mtp_pass_through():
    assert is_managed_flag("--spec-draft-n-max") is False
    assert is_managed_flag("--spec-ngram-mod-n-match") is False
    assert is_managed_flag("--spec-ngram-mod-n-min") is False
    assert is_managed_flag("--spec-ngram-mod-n-max") is False


# ── parse_ctx_override ───────────────────────────────────────────────


@pytest.mark.parametrize(
    "args,expected",
    [
        (None, None),
        ([], None),
        (["--top-k", "20"], None),
        (["--ctx-size", "128000"], 128000),
        (["--ctx-size=128000"], 128000),
        (["-c", "128000"], 128000),
        (["-c=128000"], 128000),
        (["-c", "4096", "--ctx-size", "128000"], 128000),
    ],
)
def test_parse_ctx_override(args, expected):
    assert parse_ctx_override(args) == expected


@pytest.mark.parametrize(
    "args",
    [
        ["--ctx-size"],
        ["--ctx-size", "--top-k"],
        ["--ctx-size", "abc"],
        ["--ctx-size=abc"],
        ["-c", "-1"],
    ],
)
def test_parse_ctx_override_rejects_malformed_values(args):
    with pytest.raises(ValueError, match = "ctx-size|'-c'"):
        parse_ctx_override(args)


def test_validate_extra_args_rejects_malformed_ctx_override():
    with pytest.raises(ValueError, match = "ctx-size"):
        validate_extra_args(["--ctx-size", "abc"])


# ── parse_cache_override ─────────────────────────────────────────────


@pytest.mark.parametrize(
    "args,expected",
    [
        (None, None),
        ([], None),
        (["--top-k", "20"], None),
        (["--cache-type-k", "q8_0"], "q8_0"),
        (["-ctk", "q4_0"], "q4_0"),
        (["-ctv", "q4_0"], "q4_0"),
        (["--cache-type-k=q4_0"], "q4_0"),
        (["-ctk", "f16", "-ctk", "q8_0"], "q8_0"),
    ],
)
def test_parse_cache_override(args, expected):
    assert parse_cache_override(args) == expected


@pytest.mark.parametrize(
    "args",
    [
        ["-ctk"],
        ["-ctk", "-c", "4096"],
    ],
)
def test_parse_cache_override_rejects_malformed_values(args):
    with pytest.raises(ValueError, match = "cache-type|'-ctk'"):
        parse_cache_override(args)


@pytest.mark.parametrize(
    "args, expected",
    [
        (["--cache-type-k", "f32", "--cache-type-v", "f16"], ("f32", "f16")),
        (["-ctk", "q8_0", "-ctv", "q4_0"], ("q8_0", "q4_0")),
        (["--cache-type-k=f32"], ("f32", None)),
        (["--cache-type-v", "f16"], (None, "f16")),
        (["-c", "4096"], (None, None)),
        (None, (None, None)),
        # Last-wins is kept per axis.
        (["-ctk", "f16", "-ctk", "f32"], ("f32", None)),
    ],
)
def test_parse_cache_override_per_axis(args, expected):
    # Unlike parse_cache_override (collapses both axes to one last-wins value),
    # this keeps K and V apart so an asymmetric cache can be budgeted per axis.
    assert parse_cache_override_per_axis(args) == expected


def test_resolve_cache_type_kv_uses_override_when_present():
    assert resolve_cache_type_kv(["--cache-type-k", "q8_0"], "f16") == "q8_0"


def test_resolve_cache_type_kv_uses_fallback_without_override():
    assert resolve_cache_type_kv(["--top-k", "20"], "f16") == "f16"


def test_strip_shadowing_flags_boolean_does_not_consume_next_token():
    # `--spec-default` is boolean; drop just the flag, keep the next token.
    out = strip_shadowing_flags(["--spec-default", "ngram-mod"], strip_spec = True)
    assert out == ["ngram-mod"]


def test_strip_shadowing_flags_jinja_boolean_preserves_positional():
    out = strip_shadowing_flags(["--jinja", "trailing-positional"], strip_template = True)
    assert out == ["trailing-positional"]


def test_strip_shadowing_flags_no_jinja_boolean_preserves_positional():
    out = strip_shadowing_flags(["--no-jinja", "trailing-positional"], strip_template = True)
    assert out == ["trailing-positional"]


def test_strip_shadowing_flags_equals_form_drops_only_the_flag():
    out = strip_shadowing_flags(["--ctx-size=4096", "--seed", "-1"], strip_context = True)
    assert out == ["--seed", "-1"]


def test_strip_shadowing_flags_handles_none_input():
    assert strip_shadowing_flags(None) == []


def test_strip_shadowing_flags_handles_empty_input():
    assert strip_shadowing_flags([]) == []


def test_strip_shadowing_flags_defaults_strip_everything():
    # The route's already-loaded comparator calls with no kwargs to
    # detect ANY shadowing flag in stored extras.
    out = strip_shadowing_flags(
        ["-c", "4096", "--cache-type-k", "q8_0", "--spec-default", "--jinja"]
    )
    assert out == []


# ── --split-mode (Tensor Parallelism toggle) ─────────────────────────
# Soft-shadowed exactly like --cache-type-*: pass-through allowed (keeps
# the row/none/layer modes the boolean toggle doesn't expose), stripped
# on inherit, and reconciled back into the round-tripped tensor_parallel
# state.


@pytest.mark.parametrize(
    "args",
    [
        ["--split-mode", "tensor"],
        ["--split-mode", "row"],
        ["--split-mode", "none"],
        ["--split-mode", "layer"],
        ["-sm", "tensor"],
        ["--split-mode=row"],
        ["-sm=tensor"],
    ],
)
def test_split_mode_passes_through(args):
    # Not denylisted -- a user keeps row/none/layer via extras.
    assert validate_extra_args(args) == args


def test_split_mode_is_not_managed():
    assert is_managed_flag("--split-mode") is False
    assert is_managed_flag("-sm") is False


@pytest.mark.parametrize(
    "args,expected",
    [
        (None, None),
        ([], None),
        (["--top-k", "20"], None),
        (["--split-mode", "tensor"], "tensor"),
        (["--split-mode", "row"], "row"),
        (["-sm", "none"], "none"),
        (["--split-mode=layer"], "layer"),
        (["-sm=tensor"], "tensor"),
        # last-wins when supplied twice
        (["-sm", "row", "--split-mode", "tensor"], "tensor"),
    ],
)
def test_parse_split_mode_override(args, expected):
    assert parse_split_mode_override(args) == expected


@pytest.mark.parametrize(
    "args",
    [
        ["--split-mode"],
        ["-sm"],
        ["--split-mode", "-c", "4096"],  # next token is a flag, not a value
    ],
)
def test_parse_split_mode_override_rejects_malformed_values(args):
    with pytest.raises(ValueError, match = "split-mode|'-sm'"):
        parse_split_mode_override(args)


def test_validate_extra_args_rejects_malformed_split_mode():
    # Validation catches a value-less --split-mode at the boundary,
    # mirroring the early --ctx-size / --cache-type checks.
    with pytest.raises(ValueError, match = "split-mode"):
        validate_extra_args(["--split-mode"])


@pytest.mark.parametrize(
    "args,fallback,expected",
    [
        # No override -> fall back to the toggle value, both directions.
        (["--top-k", "20"], True, True),
        (["--top-k", "20"], False, False),
        (None, True, True),
        ([], False, False),
        # Explicit override wins: tensor -> on, anything else -> off,
        # regardless of the toggle fallback.
        (["--split-mode", "tensor"], False, True),
        (["-sm", "tensor"], False, True),
        (["--split-mode", "row"], True, False),
        (["--split-mode", "none"], True, False),
        (["--split-mode", "layer"], True, False),
        (["--split-mode=tensor"], False, True),
        # Case-insensitive on the mode string.
        (["--split-mode", "TENSOR"], False, True),
        # last-wins across multiple --split-mode flags.
        (["-sm", "tensor", "--split-mode", "row"], True, False),
    ],
)
def test_resolve_tensor_parallel(args, fallback, expected):
    assert resolve_tensor_parallel(args, fallback) is expected


def test_strip_shadowing_flags_drops_split_mode_when_requested():
    out = strip_shadowing_flags(
        ["--split-mode", "row", "--top-k", "20"],
        strip_context = False,
        strip_cache = False,
        strip_spec = False,
        strip_template = False,
        strip_split_mode = True,
    )
    assert out == ["--top-k", "20"]


def test_extra_args_disable_mmproj_detects_flag():
    assert extra_args_disable_mmproj(["--no-mmproj"]) is True
    assert extra_args_disable_mmproj(["--threads", "12", "--no-mmproj"]) is True
    assert extra_args_disable_mmproj(["--no-mmproj-auto"]) is True


def test_extra_args_disable_mmproj_false_when_absent():
    assert extra_args_disable_mmproj(None) is False
    assert extra_args_disable_mmproj(["--threads", "12"]) is False


def test_extra_args_disable_mmproj_last_wins():
    assert extra_args_disable_mmproj(["--no-mmproj", "--mmproj-auto"]) is False
    assert extra_args_disable_mmproj(["--mmproj-auto", "--no-mmproj-auto"]) is True


def test_strip_shadowing_flags_drops_model_draft_with_spec():
    # --model-draft (and aliases) are Studio-managed since the separate
    # MTP drafter support: an inherited copy must not last-wins-override
    # the auto-detected drafter.
    out = strip_shadowing_flags(
        ["--model-draft", "/old/mtp.gguf", "-md", "/old2.gguf", "--top-k", "20"],
        strip_context = False,
        strip_cache = False,
        strip_spec = True,
        strip_template = False,
    )
    assert out == ["--top-k", "20"]


@pytest.mark.parametrize(
    "selector",
    [
        ["--spec-draft-hf", "org/repo"],
        ["-hfd", "org/repo"],
        ["-hfrd", "org/repo"],
        ["--hf-repo-draft", "org/repo"],
        ["--spec-draft-hf=org/repo"],
    ],
)
def test_strip_shadowing_flags_drops_hf_drafter_selectors_with_spec(selector):
    # HF drafter selectors must reset on inherit like local --model-draft, or a
    # stale inherited HF drafter last-wins over Studio's re-derived spec choice.
    out = strip_shadowing_flags(
        selector + ["--top-k", "20"],
        strip_context = False,
        strip_cache = False,
        strip_spec = True,
        strip_template = False,
    )
    assert out == ["--top-k", "20"]


def test_strip_shadowing_flags_keeps_draft_tuning_with_spec():
    # Per-drafter tuning knobs are deliberately preserved: the VRAM budget reads
    # them via the same parsers the child honors (so they stay consistent on
    # inherit), and stripping --spec-draft-ngl would move a CPU drafter to GPU.
    keep = [
        "--spec-draft-type-k",
        "q4_0",
        "--spec-draft-type-v",
        "q4_0",
        "--spec-draft-ngl",
        "0",
        "--spec-draft-device",
        "cpu",
    ]
    out = strip_shadowing_flags(
        list(keep),
        strip_context = False,
        strip_cache = False,
        strip_spec = True,
        strip_template = False,
    )
    assert out == keep


def test_strip_shadowing_flags_keeps_split_mode_when_not_requested():
    # No tensor_parallel field supplied on the Apply -> an inherited
    # --split-mode survives (mirrors the chat-template keep behavior).
    out = strip_shadowing_flags(
        ["--split-mode", "row", "--top-k", "20"],
        strip_context = True,
        strip_cache = True,
        strip_spec = True,
        strip_template = True,
        strip_split_mode = False,
    )
    assert out == ["--split-mode", "row", "--top-k", "20"]


def test_strip_shadowing_flags_drops_split_mode_short_alias_and_equals():
    assert strip_shadowing_flags(["-sm", "tensor", "--top-k", "20"], strip_split_mode = True) == [
        "--top-k",
        "20",
    ]
    assert strip_shadowing_flags(["--split-mode=row", "--seed", "-1"], strip_split_mode = True) == [
        "--seed",
        "-1",
    ]


def test_strip_shadowing_flags_defaults_strip_split_mode_too():
    # The route's already-loaded comparator (no kwargs) must see a stored
    # --split-mode as a shadowing flag so it forces a reload.
    assert strip_shadowing_flags(["--split-mode", "tensor"]) == []


@pytest.mark.parametrize(
    "args",
    [
        ["--split-mode", "tensor", "-c", "4096"],
        ["-sm", "tensor", "-c", "4096"],
        ["--split-mode=tensor", "-c", "4096"],
        ["-sm=tensor", "-c", "4096"],
    ],
)
def test_strip_split_mode_only_keeps_other_shadow_flags(args):
    # Every --split-mode form (long/short, space/=) is dropped; -c survives.
    assert strip_split_mode_only(args) == ["-c", "4096"]


def test_strip_split_mode_only_preserves_none_and_empty():
    # None means "inherit"; [] means "explicit empty" -- both must round-trip.
    assert strip_split_mode_only(None) is None
    assert strip_split_mode_only([]) == []


def test_strip_shadowing_flags_drops_tensor_split_with_split_mode():
    # --tensor-split is coupled to the split mode: stripped together so a stale
    # ratio can't override Studio's computed tensor split. Other flags survive.
    out = strip_shadowing_flags(
        ["--split-mode", "row", "--tensor-split", "1,1", "--top-k", "20"],
        strip_context = False,
        strip_cache = False,
        strip_spec = False,
        strip_template = False,
        strip_split_mode = True,
    )
    assert out == ["--top-k", "20"]


def test_strip_shadowing_flags_keeps_tensor_split_when_not_requested():
    # strip_split_mode=False keeps the whole split group (mode + ratios).
    assert strip_shadowing_flags(
        ["--tensor-split", "1,1", "--top-k", "20"], strip_split_mode = False
    ) == ["--tensor-split", "1,1", "--top-k", "20"]


def test_strip_split_mode_only_drops_tensor_split_too():
    # Downgrade / layer fallback must drop the coupled --tensor-split (all forms).
    assert strip_split_mode_only(
        ["--split-mode", "tensor", "--tensor-split", "1,1", "-c", "4096"]
    ) == ["-c", "4096"]
    assert strip_split_mode_only(["-sm=tensor", "-ts=3,1"]) == []


def test_strip_shadowing_flags_keeps_model_draft_without_spec():
    out = strip_shadowing_flags(
        ["--model-draft", "/custom/mtp.gguf"],
        strip_context = True,
        strip_cache = False,
        strip_spec = False,
        strip_template = False,
    )
    assert out == ["--model-draft", "/custom/mtp.gguf"]
