Files
wildminder-ComfyUI-Chatterbox/tests/test_text_processing.py
T
WildAi dc29843483 test: add mock-based suite
Vendored tree mocked at collection, so the suite runs
green without perth, diffusers, s3tokenizer or conformer.
Frozen widget snapshot pins node ids and widget order.
2026-10-06 18:36:08 +03:00

380 lines
16 KiB
Python

"""S8.3 -- the tag engine: five features, in one order, that order load-bearing.
A tag engine that runs its passes in the wrong order does not crash. It quietly
produces different speech: ``[Alice] <giggle>`` loses its character if the
angle-bracket pass runs first, ``[de:Alice]`` becomes a voice called
``"de:Alice"`` if the character pass claims it, and ``[pause:1.5]`` says the
word "pause" out loud. None of that is catchable except by asserting the *order*,
which is why the ordering tests below are about which stage produced a value and
not only about what the final text says.
Every documented tag form is parsed here, the three interchangeable pause words
are parsed with each documented unit, unsupported per-segment parameters are
shown to be ignored rather than fatal, and ``auto``'s chunk-method selection is
pinned at every boundary the corpus names -- including 500, where the strict
comparison makes the answer surprising and therefore worth a test.
"""
from __future__ import annotations
import pytest
from ComfyUI_Chatterbox.modules.text_processing import (
AUTO_CHUNK_THRESHOLDS,
CHUNK_COMBINATION_METHODS,
CRASH_PROTECTION_TEMPLATE,
MIN_SAFE_SEGMENT_CHARS,
PARAM_ALIASES,
TURBO_TAGS,
V2_SPECIAL_TOKENS,
apply_crash_protection,
chunk_text,
parse_duration,
plan_text,
resolve_v2_tokens,
select_chunk_method,
split_segments,
)
# ---------------------------------------------------------------------------
# Durations and pause tags (stage 3)
# ---------------------------------------------------------------------------
class TestDurations:
@pytest.mark.parametrize(
("value", "expected"),
[
("1.5", 1.5),
("2s", 2.0),
("500ms", 0.5),
("800MS", 0.8),
(" 1200 ms ", 1.2),
("3", 3.0),
("0.25s", 0.25),
(".5", 0.5),
],
)
def test_documented_spellings_parse(self, value: str, expected: float) -> None:
assert parse_duration(value) == pytest.approx(expected)
@pytest.mark.parametrize("value", ["", "abc", "5 parsecs", "-1", "1.2.3"])
def test_nonsense_is_refused(self, value: str) -> None:
"""A refusal is caught by the caller, which leaves the tag as literal text."""
with pytest.raises(ValueError):
parse_duration(value)
class TestPauseTagsAreInterchangeable:
"""``pause`` / ``wait`` / ``stop`` are three spellings of one tag."""
@pytest.mark.parametrize("word", ["pause", "wait", "stop"])
@pytest.mark.parametrize(
("value", "expected"),
[("1.5", 1.5), ("2s", 2.0), ("500ms", 0.5)],
)
def test_all_three_words_read_all_three_units(
self, word: str, value: str, expected: float
) -> None:
segments = split_segments(f"Hello there. [{word}:{value}] And more words.")
assert len(segments) == 2, "a pause opens a new segment"
assert segments[0].pause_after == pytest.approx(expected)
assert segments[0].text == "Hello there."
assert segments[1].text == "And more words."
assert segments[1].pause_after == 0.0
def test_the_word_is_case_insensitive(self) -> None:
assert split_segments("Hi. [PAUSE:2s] There.")[0].pause_after == 2.0
def test_an_unreadable_duration_is_kept_as_text_not_fatal(self) -> None:
segments = split_segments("Hi. [pause:soon] There.")
assert len(segments) == 1
assert "[pause:soon]" in segments[0].text
# ---------------------------------------------------------------------------
# Character switching (stage 1) and language tags (stage 2)
# ---------------------------------------------------------------------------
class TestCharacterTags:
def test_a_bare_name_opens_a_segment(self) -> None:
segments = split_segments("[Alice] Hi there, Bob.")
assert len(segments) == 1
assert segments[0].character == "Alice"
assert segments[0].text == "Hi there, Bob."
def test_an_untagged_run_stays_in_the_same_segment(self) -> None:
assert len(split_segments("Just talking, with no tags at all.")) == 1
def test_two_characters_are_two_segments(self) -> None:
segments = split_segments("[Alice] One. [Bob] Two.")
assert [s.character for s in segments] == ["Alice", "Bob"]
assert [s.text for s in segments] == ["One.", "Two."]
def test_a_language_tag_is_not_a_character_named_de_alice(self) -> None:
"""The regression the ordering exists to prevent."""
segments = split_segments("[de:Alice] Hallo.")
assert len(segments) == 1
assert segments[0].character == "Alice"
assert segments[0].language == "de"
assert segments[0].text == "Hallo."
def test_a_display_name_resolves_to_its_code(self) -> None:
segments = split_segments("[German:Alice] Hallo.")
assert segments[0].language == "de"
assert segments[0].character == "Alice"
def test_a_language_tag_may_carry_no_character(self) -> None:
segments = split_segments("[de:] Hallo.")
assert segments[0].language == "de"
assert segments[0].character is None
def test_a_pause_tag_is_not_a_character_named_pause(self) -> None:
"""It is a pause, and a leading one has nothing to be silent after."""
segments = split_segments("[pause:1.5] Hallo.")
assert len(segments) == 1
assert segments[0].character is None
assert segments[0].text == "Hallo."
assert segments[0].pause_after == 0.0, (
"a pause with no text before it has no segment to attach to"
)
# ---------------------------------------------------------------------------
# Per-segment overrides (stage 4)
# ---------------------------------------------------------------------------
class TestPerSegmentOverrides:
def test_order_does_not_matter(self) -> None:
first = split_segments("[Alice|seed:42|temp:0.5] Hi.")[0]
second = split_segments("[Alice|temp:0.5|seed:42] Hi.")[0]
assert first.params == second.params
assert first.params == {"seed": "42", "temperature": "0.5"}
def test_case_does_not_matter(self) -> None:
segment = split_segments("[Alice|SEED:42|Temp:0.5] Hi.")[0]
assert segment.params == {"seed": "42", "temperature": "0.5"}
@pytest.mark.parametrize(
("alias", "canonical"),
[("temp", "temperature"), ("cfg", "cfg_weight"),
("cfg_weight", "cfg_weight"), ("exag", "exaggeration")],
)
def test_documented_aliases_resolve(self, alias: str, canonical: str) -> None:
segment = split_segments(f"[Alice|{alias}:0.5] Hi.")[0]
assert segment.params == {canonical: "0.5"}
assert canonical in PARAM_ALIASES.values()
def test_an_unsupported_parameter_is_ignored_not_fatal(self) -> None:
"""A script carried over from another tool must still run."""
segment = split_segments("[Alice|batch:4|temp:0.5] Hi.")[0]
assert segment.params == {"temperature": "0.5"}
assert segment.character == "Alice"
assert segment.text == "Hi."
def test_a_parameter_with_no_socket_at_all_is_ignored(self) -> None:
segment = split_segments("[Alice|batch_size:8] Hi.")[0]
assert segment.params == {}
assert segment.text == "Hi."
# ---------------------------------------------------------------------------
# v2 angle-bracket tokens (stage 5) -- and the order it runs in
# ---------------------------------------------------------------------------
class TestV2Tokens:
def test_all_thirty_documented_names_are_known(self) -> None:
assert len(V2_SPECIAL_TOKENS) == 30
assert len(set(V2_SPECIAL_TOKENS)) == 30
@pytest.mark.parametrize("token", V2_SPECIAL_TOKENS)
def test_every_documented_token_is_recognised(self, token: str) -> None:
resolved, seen = resolve_v2_tokens(f"Hello <{token}> there.")
assert seen == (token,), f"<{token}> was not recognised"
assert resolved == f"Hello <{token}> there."
def test_casing_is_normalised_except_where_it_is_significant(self) -> None:
assert resolve_v2_tokens("<GIGGLE>")[0] == "<giggle>"
assert resolve_v2_tokens("<UH>")[0] == "<UH>"
assert resolve_v2_tokens("<um>")[0] == "<UM>"
def test_an_unknown_angle_token_is_left_alone(self) -> None:
"""The upstream tokenizer carries 118 special tokens; only 30 are named.
Deleting a word the model can still speak is worse than passing it on.
"""
resolved, seen = resolve_v2_tokens("Hello <not_a_real_token> there.")
assert seen == ()
assert resolved == "Hello <not_a_real_token> there."
def test_a_giggle_after_a_character_resolves_after_the_character(self) -> None:
"""The load-bearing ordering assertion.
Stage 5 must not have run yet when the character is resolved, or
``[Alice] <giggle>`` loses its voice.
"""
segments = split_segments("[Alice] <giggle> Hello there, nice to meet you.")
assert len(segments) == 1
assert segments[0].character == "Alice", (
"the character was not resolved; stage 5 ran before stage 1"
)
assert "<giggle>" in segments[0].text, (
"the v2 token was rewritten during the character stage; it must still "
"be untouched text when the character resolves"
)
plan = plan_text("[Alice] <giggle> Hello there, nice to meet you.")
assert plan.segments[0].character == "Alice"
assert "<giggle>" in plan.text
assert "[" not in plan.text
def test_the_same_example_composes_with_a_pause(self) -> None:
"""The corpus's own worked example, end to end."""
plan = plan_text(
"[Alice] Hello! <giggle> Nice to meet you. [pause:0.5] How are you? "
"<whisper> I have a secret."
)
assert [s.character for s in plan.segments] == ["Alice", None]
assert plan.segments[0].pause_after == pytest.approx(0.5)
assert plan.segments[1].pause_after == 0.0
assert "<giggle>" in plan.text
assert "<whisper>" in plan.text
class TestTurboTagsAreADifferentVocabulary:
"""``[laugh]`` is a character name here, and a Turbo tag there."""
def test_square_brackets_are_a_character_on_the_classic_path(self) -> None:
segments = split_segments("[laugh] Hello.")
assert segments[0].character == "laugh"
def test_the_same_text_is_a_tag_on_the_turbo_path(self) -> None:
segments = split_segments("[laugh] Hello.", turbo_tags=True)
assert segments[0].character is None
assert segments[0].turbo_tags == ("laugh",)
def test_turbo_tags_are_recognised_case_insensitively(self) -> None:
assert split_segments("[Clear Throat] Hi.", turbo_tags=True)[0].turbo_tags == (
"clear throat",
)
def test_the_turbo_set_is_not_the_v2_set(self) -> None:
"""Nine square-bracket tokens against thirty angle-bracket ones.
Five names are shared between the two vocabularies (``sigh``, ``gasp``,
``cough``, ``sniff``, ``groan``); what differs is the *syntax* each
vocabulary uses, which is the point of this module.
"""
assert len(TURBO_TAGS) == 9
assert len(V2_SPECIAL_TOKENS) == 30
shared = set(TURBO_TAGS) & {token.lower() for token in V2_SPECIAL_TOKENS}
assert shared == {"sigh", "gasp", "cough", "sniff", "groan"}
assert "clear throat" not in V2_SPECIAL_TOKENS
assert "clear_throat" not in TURBO_TAGS
def test_an_angle_token_is_still_normalised_on_the_turbo_path(self) -> None:
segments = split_segments("[laugh] <giggle> Hi.", turbo_tags=True)
assert segments[0].turbo_tags == ("laugh",)
assert "<giggle>" in segments[0].text
# ---------------------------------------------------------------------------
# Chunking
# ---------------------------------------------------------------------------
class TestAutoChunkMethodBoundaries:
"""``auto`` selects by length, and both comparisons are strict.
The corpus phrases the rule as "<500 -> concatenate" while the thresholds are
written as ">1000" and ">500". Those disagree at exactly 500, so the strict
reading is implemented and 500 is pinned here -- a rule with an unstated
boundary is a rule with a bug in it.
"""
@pytest.mark.parametrize(
("chars", "expected"),
[
(499, "concatenate"),
(500, "concatenate"),
(501, "crossfade"),
(1000, "crossfade"),
(1001, "silence_padding"),
(5000, "silence_padding"),
],
)
def test_the_documented_boundaries(self, chars: int, expected: str) -> None:
assert select_chunk_method("auto", chars) == expected
assert plan_text("x" * chars).chunk_method == expected
def test_the_thresholds_are_published_not_implicit(self) -> None:
assert AUTO_CHUNK_THRESHOLDS == ((1000, "silence_padding"), (500, "crossfade"))
@pytest.mark.parametrize(
"method", [name for name in CHUNK_COMBINATION_METHODS if name != "auto"]
)
def test_an_explicit_method_passes_through(self, method: str) -> None:
"""Whatever the user picked is what runs, at any length.
``auto`` is the only value that gets *replaced*; the other three are
names, not hints, and a short script must not quietly switch the user
off ``silence_padding`` because it was under a thousand characters.
"""
assert select_chunk_method(method, 10) == method
assert select_chunk_method(method, 5000) == method
def test_the_resolved_method_is_never_auto(self) -> None:
for chars in (0, 1, 500, 5000):
assert select_chunk_method("auto", chars) != "auto"
class TestChunking:
def test_a_short_script_is_one_chunk(self) -> None:
assert chunk_text("Short enough.", max_chars_per_chunk=400) == ["Short enough."]
def test_a_long_script_is_cut(self) -> None:
text = "A sentence here. " * 100
chunks = chunk_text(text, 200)
assert len(chunks) > 1
assert all(len(chunk) <= 200 for chunk in chunks)
assert "".join(chunks).replace(" ", "") == text.replace(" ", "")
def test_cutting_is_disabled_by_the_flag(self) -> None:
text = "A sentence here. " * 100
assert chunk_text(text, 200, enabled=False) == [text]
def test_a_crash_protected_short_script_is_still_short(self) -> None:
plan = plan_text("Hi", enable_chunking=True, max_chars_per_chunk=400)
assert len(plan.chunks) == 1
# ---------------------------------------------------------------------------
# The whole pipeline
# ---------------------------------------------------------------------------
class TestPlanText:
def test_a_plain_script_yields_one_segment_and_one_chunk(self) -> None:
plan = plan_text("Hello there, this is a plain script.")
assert len(plan.segments) == 1
assert plan.chunks == ("Hello there, this is a plain script.",)
assert plan.char_count == len(plan.text)
def test_blank_input_is_an_empty_plan_not_an_error(self) -> None:
plan = plan_text(" ")
assert plan.segments == ()
assert plan.chunks == ("",)
def test_characters_are_listed_in_first_appearance_order(self) -> None:
plan = plan_text("[Bob] One. [Alice] Two. [Bob] Three.")
assert plan.characters == ("Bob", "Alice")
def test_the_default_template_is_the_documented_one(self) -> None:
assert CRASH_PROTECTION_TEMPLATE == "hmm ,, {seg} hmm ,,"
assert MIN_SAFE_SEGMENT_CHARS == 21
assert apply_crash_protection("Short.").startswith("hmm ,,")