Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
346 lines
16 KiB
Python
346 lines
16 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")
|
|
monkeypatch.setattr(config, "SHORTCUT_CATEGORIES_FILE", tmp_path / "cats.json")
|
|
monkeypatch.setattr(config, "REVOLVER_FILE", tmp_path / "rev.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"
|
|
|
|
|
|
# ── categories, export, import ───────────────────────────────────────────
|
|
|
|
def test_a_made_category_survives_empty_and_a_used_one_needs_no_entry(store):
|
|
shortcuts.add_category("Camera", "Moves")
|
|
shortcuts.save({"name": "x", "triggers": ["x"], "replacements": ["y"], "category": "Light"})
|
|
assert shortcuts.categories() == [
|
|
{"name": "Camera", "sub_categories": ["Moves"]}, {"name": "Light", "sub_categories": []}]
|
|
with pytest.raises(ValueError):
|
|
shortcuts.add_category(" ")
|
|
shortcuts.clear()
|
|
assert shortcuts.categories() == []
|
|
|
|
|
|
def test_export_then_replace_import_is_the_same_library(store):
|
|
shortcuts.save({"name": "Fox", "triggers": ["fox", "vixen"], "replacements": ["red fox", ""],
|
|
"category": "Animals", "sub_category": "Wild"})
|
|
shortcuts.add_category("Empty")
|
|
payload = shortcuts.export_payload()
|
|
shortcuts.clear()
|
|
assert shortcuts.import_payload(payload, "replace") == 1
|
|
assert [s.to_dict() for s in shortcuts.listing()] == payload["shortcuts"]
|
|
assert shortcuts.categories() == payload["categories"]
|
|
|
|
|
|
def test_import_reads_a_v4_file_and_merge_keeps_what_is_here(store):
|
|
shortcuts.save({"name": "Keep", "triggers": ["keep"], "replacements": ["k"]})
|
|
v4 = {"version": 1, "shortcuts": {"fox": {"name": "Fox", "activation_words": ["fox"],
|
|
"replacement": ["red fox"], "refinement_key": "k1", "category": "Animals"},
|
|
"bad": {"name": "NoTrigger"}}, "categories": [{"name": "Animals", "sub_categories": ["Wild"]}]}
|
|
assert shortcuts.import_payload(v4, "merge") == 1
|
|
assert sorted(s.name for s in shortcuts.listing()) == ["Fox", "Keep"]
|
|
assert shortcuts.categories() == [{"name": "Animals", "sub_categories": ["Wild"]}]
|
|
shortcuts.import_payload(v4, "merge")
|
|
assert len(shortcuts.listing()) == 2 # same name replaces, never duplicates
|
|
|
|
|
|
def test_import_refuses_what_holds_nothing_and_changes_nothing(store):
|
|
shortcuts.save({"name": "Keep", "triggers": ["keep"], "replacements": ["k"]})
|
|
for bad in ({"shortcuts": {"a": {"name": "no trigger"}}}, "nope", {"shortcuts": 5}):
|
|
with pytest.raises(ValueError):
|
|
shortcuts.import_payload(bad, "replace")
|
|
with pytest.raises(ValueError):
|
|
shortcuts.import_payload([{"triggers": ["a"]}], "bogus")
|
|
assert [s.name for s in shortcuts.listing()] == ["Keep"]
|
|
|
|
|
|
def test_a_bad_categories_shape_is_refused_before_anything_is_written(store):
|
|
shortcuts.save({"name": "Keep", "triggers": ["keep"], "replacements": ["k"]})
|
|
ok = [{"name": "A", "triggers": ["a"], "replacements": ["1"]}]
|
|
# a wrong-typed sub_categories is ignored, not exploded into letters or crashed on
|
|
assert shortcuts.import_payload({"shortcuts": ok, "categories": [{"name": "C", "sub_categories": 5}, {"name": "D", "sub_categories": "Wild"}]}, "replace") == 1
|
|
assert [s.name for s in shortcuts.listing()] == ["A"]
|
|
assert shortcuts.categories() == [{"name": "C", "sub_categories": []}, {"name": "D", "sub_categories": []}]
|
|
|
|
|
|
def test_names_are_one_identity_everywhere(store):
|
|
shortcuts.save({"name": "Fox", "triggers": ["fox"], "replacements": ["a"]})
|
|
shortcuts.save({"name": "fox", "triggers": ["fox2"], "replacements": ["b"]}) # same shortcut, updated
|
|
assert [s.triggers for s in shortcuts.listing()] == [["fox2"]]
|
|
shortcuts.save({"name": "Cat", "triggers": ["cat"], "replacements": ["c"]})
|
|
with pytest.raises(ValueError): # a rename onto an existing name
|
|
shortcuts.save({"name": "CAT", "triggers": ["cat"], "replacements": ["c"]}, original_name="fox")
|
|
assert sorted(s.name for s in shortcuts.listing()) == ["Cat", "fox"]
|
|
|
|
|
|
def test_import_counts_what_was_stored_and_the_key_names_a_nameless_row(store):
|
|
data = {"shortcuts": {"Alpha": {"name": "", "triggers": ["x", "y"]},
|
|
"b": {"triggers": ["x", "z"]}, "b2": {"name": "B", "triggers": ["q"]}}}
|
|
assert shortcuts.import_payload(data) == 2 # "b" and "B" are one name: the later row wins
|
|
assert sorted(s.name for s in shortcuts.listing()) == ["Alpha", "B"]
|
|
|
|
|
|
def test_odd_types_never_become_their_repr(store):
|
|
shortcuts.save({"name": "T", "triggers": "a, b;c", "replacements": [{"a": 1}, "ok"], "category": 5, "sub_category": ["x"]})
|
|
s = shortcuts.listing()[0]
|
|
assert s.triggers == ["a", "b", "c"] and s.replacements == ["ok"] and (s.category, s.sub_category) == ("", "")
|
|
with pytest.raises(ValueError):
|
|
shortcuts.add_category(5)
|
|
|
|
|
|
def test_replacements_are_prose_split_on_newlines_only(store):
|
|
s = shortcuts.Shortcut.from_dict({"name": "P", "triggers": ["p"], "replacements": "a big, red fox; run\nsecond"})
|
|
assert s.replacements == ["a big, red fox; run", "second"]
|
|
|
|
|
|
def test_a_library_with_case_duplicates_edits_and_deletes_the_named_one(store):
|
|
shortcuts._save_all([shortcuts.Shortcut(name="Fox", triggers=["a"], replacements=["1"]),
|
|
shortcuts.Shortcut(name="fox", triggers=["b"], replacements=["2"])])
|
|
shortcuts.save({"name": "fox", "triggers": ["b2"], "replacements": ["2"]}, original_name="fox")
|
|
assert [(s.name, s.triggers) for s in shortcuts.listing()] == [("Fox", ["a"]), ("fox", ["b2"])]
|
|
shortcuts.delete("fox")
|
|
assert [s.name for s in shortcuts.listing()] == ["Fox"]
|
|
shortcuts.delete("FOX") # no exact match: the case-insensitive one goes
|
|
assert shortcuts.listing() == []
|
|
|
|
|
|
# ── revolver ─────────────────────────────────────────────────────────────
|
|
|
|
def _abc(store):
|
|
shortcuts.save({"name": "Letter", "triggers": ["lt"], "replacements": ["A", "B", "C"]})
|
|
shortcuts.save({"name": "Solo", "triggers": ["solo"], "replacements": ["only"]})
|
|
|
|
|
|
def test_off_by_default_and_a_peek_never_saves(store):
|
|
_abc(store)
|
|
assert shortcuts.revolver_settings() == {"enabled": False, "random": False}
|
|
shortcuts.set_revolver_settings(enabled=True)
|
|
first = shortcuts.expand("lt lt lt solo", seed=1)
|
|
assert first == "A B C only"
|
|
assert shortcuts.expand("lt lt lt solo", seed=2) == "A B C only" # peeked: same answer again
|
|
|
|
|
|
def test_commit_advances_the_cycle_across_calls_and_wraps(store):
|
|
_abc(store)
|
|
shortcuts.set_revolver_settings(enabled=True)
|
|
got = [shortcuts.expand("lt", seed=i, commit=True) for i in range(1, 8)]
|
|
assert got == ["A", "B", "C", "A", "B", "C", "A"]
|
|
|
|
|
|
def test_random_cycle_repeats_nothing_until_the_set_is_spent(store):
|
|
_abc(store)
|
|
shortcuts.set_revolver_settings(enabled=True, random_order=True)
|
|
for seed in (1, 2):
|
|
round_ = [shortcuts.expand("lt", seed=seed * 10 + i, commit=True) for i in range(3)]
|
|
assert sorted(round_) == ["A", "B", "C"]
|
|
|
|
|
|
def test_editing_the_replacements_or_flipping_a_mode_restarts_the_cycle(store):
|
|
_abc(store)
|
|
shortcuts.set_revolver_settings(enabled=True)
|
|
shortcuts.expand("lt", commit=True)
|
|
shortcuts.save({"name": "Letter", "triggers": ["lt"], "replacements": ["X", "Y"]})
|
|
assert shortcuts.expand("lt", commit=True) == "X"
|
|
shortcuts.set_revolver_settings(random_order=True)
|
|
assert shortcuts.load_revolver()["state"] == {}
|
|
shortcuts.set_revolver_settings(random_order="yes") # not a bool: ignored
|
|
assert shortcuts.revolver_settings()["random"] is True
|
|
|
|
|
|
def test_a_corrupt_revolver_file_means_off_not_a_crash(store):
|
|
config.REVOLVER_FILE.write_text("{nope", encoding="utf-8")
|
|
assert shortcuts.revolver_settings() == {"enabled": False, "random": False}
|
|
_abc(store)
|
|
assert shortcuts.expand("lt", seed=3) in ("A", "B", "C")
|