A variable referencing the same other variable more than once is not a cycle, so memoization alone still leaves the OUTPUT exponential in size (each level's re.sub concatenates the memoized child result multiple times). Adds a shared output-size budget, checked before using either a cached or freshly-computed result. Also ignore the runtime shortcuts.json data file, and fix the fan-out test's assertion, which was checking against a vacuous condition.
198 lines
8.2 KiB
Python
198 lines
8.2 KiB
Python
"""The shortcut library and its expansion algorithm -- a close port of v4's
|
|
`templates.py` (`apply_prompt_shortcuts`/`resolve_variables`), pinned against
|
|
the same behaviour: longest-trigger-first, seeded multi-choice, cycle-safe
|
|
variables, undefined variables left literal.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from core import config, shortcuts
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(config, "SHORTCUTS_FILE", tmp_path / "shortcuts.json")
|
|
return tmp_path
|
|
|
|
|
|
# ── the library (CRUD) ───────────────────────────────────────────────────
|
|
|
|
def test_a_fresh_install_has_no_shortcuts(store):
|
|
assert shortcuts.listing() == []
|
|
|
|
|
|
def test_a_saved_shortcut_round_trips(store):
|
|
shortcuts.save({"name": "Golden hour", "triggers": ["golden hour"],
|
|
"replacements": ["warm golden-hour lighting"]})
|
|
items = shortcuts.listing()
|
|
assert len(items) == 1
|
|
assert items[0].name == "Golden hour"
|
|
assert items[0].triggers == ["golden hour"]
|
|
|
|
|
|
def test_a_shortcut_with_no_triggers_is_refused(store):
|
|
with pytest.raises(ValueError):
|
|
shortcuts.save({"name": "Nothing", "triggers": [], "replacements": ["x"]})
|
|
assert shortcuts.listing() == []
|
|
|
|
|
|
def test_saving_under_original_name_updates_in_place(store):
|
|
shortcuts.save({"name": "A", "triggers": ["a"], "replacements": ["1"]})
|
|
shortcuts.save({"name": "B", "triggers": ["b"], "replacements": ["2"]})
|
|
shortcuts.save({"name": "A renamed", "triggers": ["a"], "replacements": ["1!"]},
|
|
original_name="A")
|
|
items = shortcuts.listing()
|
|
assert [i.name for i in items] == ["A renamed", "B"] # position kept, not moved to the end
|
|
|
|
|
|
def test_saving_under_a_new_name_appends(store):
|
|
shortcuts.save({"name": "A", "triggers": ["a"], "replacements": ["1"]})
|
|
shortcuts.save({"name": "B", "triggers": ["b"], "replacements": ["2"]})
|
|
assert [i.name for i in shortcuts.listing()] == ["A", "B"]
|
|
|
|
|
|
def test_delete_removes_by_name(store):
|
|
shortcuts.save({"name": "A", "triggers": ["a"], "replacements": ["1"]})
|
|
shortcuts.save({"name": "B", "triggers": ["b"], "replacements": ["2"]})
|
|
shortcuts.delete("A")
|
|
assert [i.name for i in shortcuts.listing()] == ["B"]
|
|
|
|
|
|
def test_clear_empties_the_library(store):
|
|
shortcuts.save({"name": "A", "triggers": ["a"], "replacements": ["1"]})
|
|
shortcuts.clear()
|
|
assert shortcuts.listing() == []
|
|
|
|
|
|
def test_replacement_and_trigger_lists_are_cleaned(store):
|
|
shortcuts.save({"name": "A", "triggers": [" a ", "a", "b"],
|
|
"replacements": [" x ", "x", ""]})
|
|
item = shortcuts.listing()[0]
|
|
assert item.triggers == ["a", "b"] # trimmed, de-duped
|
|
assert item.replacements == ["x", ""] # "" kept -- a deliberate remove entry
|
|
|
|
|
|
# ── expansion ─────────────────────────────────────────────────────────────
|
|
|
|
def _sc(name, triggers, replacements, enabled=True):
|
|
return shortcuts.Shortcut(name=name, triggers=triggers, replacements=replacements,
|
|
enabled=enabled)
|
|
|
|
|
|
def test_a_trigger_expands_to_its_replacement():
|
|
out = shortcuts.expand("a man at golden hour", [_sc("gh", ["golden hour"], ["warm light"])])
|
|
assert out == "a man at warm light"
|
|
|
|
|
|
def test_matching_is_case_insensitive():
|
|
out = shortcuts.expand("GOLDEN HOUR scene", [_sc("gh", ["golden hour"], ["warm light"])])
|
|
assert out == "warm light scene"
|
|
|
|
|
|
def test_a_disabled_shortcut_never_matches():
|
|
out = shortcuts.expand("golden hour", [_sc("gh", ["golden hour"], ["warm light"], enabled=False)])
|
|
assert out == "golden hour"
|
|
|
|
|
|
def test_longest_trigger_wins_over_a_shorter_one_it_contains():
|
|
scs = [_sc("short", ["golden"], ["SHORT"]), _sc("long", ["golden hour"], ["LONG"])]
|
|
assert shortcuts.expand("golden hour", scs) == "LONG"
|
|
|
|
|
|
def test_a_trigger_inside_a_longer_word_does_not_match():
|
|
out = shortcuts.expand("goldenrod field", [_sc("gh", ["golden"], ["warm"])])
|
|
assert out == "goldenrod field" # word-boundary guard, not a substring replace
|
|
|
|
|
|
def test_expansion_is_deterministic_for_the_same_text():
|
|
sc = _sc("many", ["cat"], ["tabby", "siamese", "calico", "tuxedo"])
|
|
a = shortcuts.expand("a cat sits", [sc])
|
|
b = shortcuts.expand("a cat sits", [sc])
|
|
assert a == b # same text -> same seed -> same random.Random draw
|
|
|
|
|
|
def test_expansion_can_differ_for_different_seeds():
|
|
sc = _sc("many", ["cat"], ["tabby", "siamese", "calico", "tuxedo", "ragdoll", "sphynx"])
|
|
picks = {shortcuts.expand("a cat", [sc], seed=s) for s in range(1, 8)}
|
|
assert len(picks) > 1 # different explicit seeds draw different replacements
|
|
|
|
|
|
def test_an_empty_replacement_removes_the_phrase_and_its_stray_comma():
|
|
out = shortcuts.expand("tall, blurry, man", [_sc("rm", ["blurry"], [""])])
|
|
assert out == "tall, man"
|
|
|
|
|
|
def test_no_shortcuts_leaves_text_untouched():
|
|
assert shortcuts.expand("plain text", []) == "plain text"
|
|
|
|
|
|
def test_empty_text_returns_empty():
|
|
assert shortcuts.expand("", [_sc("a", ["a"], ["b"])]) == ""
|
|
|
|
|
|
def test_a_shortcut_with_no_replacements_never_matches():
|
|
assert shortcuts.expand("trigger word", [_sc("x", ["trigger"], [])]) == "trigger word"
|
|
|
|
|
|
# ── $variables ────────────────────────────────────────────────────────────
|
|
|
|
def test_a_declared_variable_is_substituted():
|
|
out = shortcuts.resolve_variables("a $subject walks", [{"name": "subject", "value": "fox"}])
|
|
assert out == "a fox walks"
|
|
|
|
|
|
def test_an_undefined_variable_is_left_literal():
|
|
out = shortcuts.resolve_variables("a $ghost walks", [])
|
|
assert out == "a $ghost walks"
|
|
|
|
|
|
def test_a_variable_may_reference_another():
|
|
out = shortcuts.resolve_variables("$a", [{"name": "a", "value": "$b"}, {"name": "b", "value": "done"}])
|
|
assert out == "done"
|
|
|
|
|
|
def test_a_self_referencing_variable_is_left_literal_not_infinitely_expanded():
|
|
out = shortcuts.resolve_variables("$a", [{"name": "a", "value": "x $a y"}])
|
|
assert out == "x $a y"
|
|
|
|
|
|
def test_a_two_variable_cycle_is_left_literal():
|
|
out = shortcuts.resolve_variables(
|
|
"$a", [{"name": "a", "value": "$b"}, {"name": "b", "value": "$a"}])
|
|
assert out == "$a" # the cycle is detected, not expanded forever
|
|
|
|
|
|
def test_the_same_cycle_resolves_differently_depending_on_which_end_is_asked_for():
|
|
# A="$B", B="$A": which one comes back literal depends on where resolution
|
|
# STARTS -- the memoization added to bound fan-out below must key on the
|
|
# stack a variable is reached through, not on its name alone, or one of
|
|
# these two would silently answer with the other's result.
|
|
variables = [{"name": "a", "value": "$b"}, {"name": "b", "value": "$a"}]
|
|
assert shortcuts.resolve_variables("$a", variables) == "$a"
|
|
assert shortcuts.resolve_variables("$b", variables) == "$b"
|
|
|
|
|
|
def test_a_variable_referencing_the_same_name_twice_is_not_a_cycle_and_stays_fast():
|
|
# "$v1 $v1" is not a cycle (v1 never appears in its own expansion chain),
|
|
# so the cycle guard never trips -- and without memoization, evaluating
|
|
# the two "$v1" occurrences independently makes a chain of variables that
|
|
# each reference the next twice cost 2**N evaluations of the deepest one.
|
|
# 24 variables to double every one of the last variable's evaluations
|
|
# would need real seconds and tens of megabytes without a cache or an
|
|
# output-size cap; this finishes near-instantly and stays bounded.
|
|
depth = 24
|
|
variables = [{"name": f"v{i}", "value": f"$v{i + 1} $v{i + 1}"} for i in range(depth)]
|
|
variables.append({"name": f"v{depth}", "value": "leaf"})
|
|
out = shortcuts.resolve_variables("$v0", variables)
|
|
assert "leaf" in out
|
|
assert len(out) <= shortcuts._VARIABLE_MAX_OUTPUT + 32 # capped, not 2**24 copies of "leaf "
|
|
|
|
|
|
def test_a_leading_dollar_in_the_declared_name_is_ignored():
|
|
out = shortcuts.resolve_variables("$subject", [{"name": "$subject", "value": "fox"}])
|
|
assert out == "fox"
|
|
|
|
|
|
def test_no_variables_declared_returns_text_unchanged():
|
|
assert shortcuts.resolve_variables("plain $x text", []) == "plain $x text"
|