test(embed): de-vacuous the model_id guards — assert the SPECIFIC message

ECC tdd pass, run as an 18-mutant sweep across the areas the earlier
per-module harnesses had not covered (topics internals, embed validation,
semdedup guards). 16 mutants were caught; the sweep found one real vacuity,
which is the 7th of this release.

test_bad_model_id_rejected asserted only
`pytest.raises((ValueError, TypeError))` over ["", "   ", None, 123, True].
That is too broad to mean anything here: with the empty-check DELETED, ""
falls straight through to the fallback refusal ("cannot verify pooling")
and still raises ValueError, so the test passed for a completely different
reason than its name claims. Proven by mutation: deleting the empty guard
left the suite green. The length cap had no test at all; the null-byte
check had none either.

Replaced with one test per guard, each asserting the message that guard
actually emits (non-empty / null byte / too long / must be str), plus a
monkeypatched _fetch_pooling_config that fails the test if the guard lets
execution reach a network fetch. Re-ran the sweep: all three guards now
CAUGHT where two previously survived.

Also added: model_id is stripped before the allowlist lookup, so
surrounding whitespace cannot defeat it.

Verified NOT vacuous (mutation-checked, no change needed): the ctfidf
top_n slice, resolve_k's cap/clamp/n<4 rules, kmeans' iteration loop,
build_topic_report's coverage denominator + gap warning, embed's row cap /
non-str / bare-string / truncation / batch bounds / L2 zero-guard /
allowlist lowercasing, semdedup's row cap / 2-D check / nearest-kept-row
provenance.

268 tests green (1 POSIX skip), ruff clean.
This commit is contained in:
Alpamys 2026-07-16 21:50:45 +05:00
parent 1eaeb8e46b
commit 6308038024
1 changed files with 47 additions and 3 deletions

View File

@ -112,13 +112,57 @@ class TestResolvePooling:
embed.resolve_pooling("org/other-encoder")
assert "max" in str(exc.value)
@pytest.mark.parametrize("bad", ["", " ", None, 123, True])
def test_bad_model_id_rejected(self, bad):
@pytest.mark.parametrize("bad", [None, 123, True, 1.5, b"bytes"])
def test_non_string_model_id_rejected(self, bad):
from soup_cli.utils.embed import resolve_pooling
with pytest.raises((ValueError, TypeError)):
with pytest.raises(TypeError, match="must be str"):
resolve_pooling(bad)
# Each guard below asserts its OWN message. A bare
# `pytest.raises((ValueError, TypeError))` was vacuous here: with the
# empty-check deleted, "" falls through to the FALLBACK refusal
# ("cannot verify pooling") and still raises ValueError, so the broad
# form could not tell the two apart — proven by mutation.
@pytest.mark.parametrize("bad", ["", " ", "\t\n"])
def test_empty_model_id_names_the_empty_guard(self, bad, monkeypatch):
from soup_cli.utils import embed
monkeypatch.setattr(
embed, "_fetch_pooling_config",
lambda mid: pytest.fail("must reject before any fetch"),
)
with pytest.raises(ValueError, match="non-empty"):
embed.resolve_pooling(bad)
def test_null_byte_model_id_names_the_null_guard(self, monkeypatch):
from soup_cli.utils import embed
monkeypatch.setattr(
embed, "_fetch_pooling_config",
lambda mid: pytest.fail("must reject before any fetch"),
)
with pytest.raises(ValueError, match="null byte"):
embed.resolve_pooling("org/model\x00evil")
def test_overlong_model_id_names_the_length_guard(self, monkeypatch):
from soup_cli.utils import embed
monkeypatch.setattr(
embed, "_fetch_pooling_config",
lambda mid: pytest.fail("must reject before any fetch"),
)
with pytest.raises(ValueError, match="too long"):
embed.resolve_pooling("o/" + "x" * (embed._MAX_MODEL_ID_CHARS + 1))
def test_model_id_is_stripped_not_just_validated(self):
from soup_cli.utils.embed import resolve_pooling
# surrounding whitespace must not defeat the allowlist
assert resolve_pooling(
" sentence-transformers/all-MiniLM-L6-v2 "
) == "mean"
class TestEmbedTexts:
def test_rejects_over_cap(self):