Shortcut revolver: no-repeat cycling through multi-replacement shortcuts

Editor ▸ Settings ▸ Editor gains a 'Shortcut revolver' toggle (+ 'Random' sub-option).
When on, each shortcut with several replacements draws from a per-shortcut queue —
sequential (first…last, then restart) or shuffled once per cycle — and nothing repeats
until the whole set has fired. Off (default) keeps the seeded random pick unchanged.

- State is a sidecar (shortcut_revolver.json) next to the shortcut DB: settings +
  per-shortcut remaining queue + replacement-set fingerprint (editing a shortcut's
  replacements restarts its cycle; changing either setting resets all cycles).
- Generation commits the draw (refine_v2 positive prompt); every preview path
  (parse_timeline, split mapping, fragments) peeks the same stored queue without
  saving, so previews show exactly what the next Generate will use.
- Batch Training variants each commit a draw: N variants sweep N consecutive
  chambers instead of seed-rolled repeats.
- FunPackStudio.IS_CHANGED returns nan while the revolver is on so fixed-seed
  re-runs aren't served from ComfyUI's node cache (the next chamber must fire).

Verified live on local ComfyUI: settings round-trip, seed-independent stable peeks,
sequential cycle REP_ONE→TWO→THREE→ONE across processes, peek-after-commit shows the
next chamber. 9 new unit tests; full suite has the same 20 pre-existing failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-07-06 05:26:21 +03:00
co-authored by Claude Fable 5
parent 1112c2dccc
commit 6528acf504
7 changed files with 411 additions and 22 deletions
+68 -14
View File
@@ -663,11 +663,13 @@ def _expand_with_map(text):
try:
from .templates import (load_shortcut_db, shortcut_list, shortcut_key,
_shortcut_replacements, _shortcut_trigger_pattern,
normalize_refinement_key)
normalize_refinement_key, load_revolver,
revolver_next_replacement)
except ImportError:
from templates import (load_shortcut_db, shortcut_list, shortcut_key,
_shortcut_replacements, _shortcut_trigger_pattern,
normalize_refinement_key)
normalize_refinement_key, load_revolver,
revolver_next_replacement)
import random
from hashlib import md5
@@ -678,7 +680,7 @@ def _expand_with_map(text):
except Exception:
return text, whole
candidates = []
for shortcut in (data.get("shortcuts", {}) or {}).values():
for db_key, shortcut in (data.get("shortcuts", {}) or {}).items():
if not isinstance(shortcut, dict) or not bool(shortcut.get("enabled", True)):
continue
reps = _shortcut_replacements(shortcut.get("replacements", shortcut.get("replacement", [])))
@@ -689,14 +691,23 @@ def _expand_with_map(text):
for trig in shortcut_list(shortcut.get("triggers", [])):
pat = _shortcut_trigger_pattern(trig)
if pat:
candidates.append((trig, pat, reps, sc_key, sc_name))
candidates.append((trig, pat, reps, sc_key, sc_name, str(db_key)))
if not candidates:
return text, whole
candidates.sort(key=lambda it: len(shortcut_key(it[0])), reverse=True)
combined = "|".join(f"(?P<t{i}>{pat})" for i, (_, pat, _, _, _) in enumerate(candidates))
combined = "|".join(f"(?P<t{i}>{pat})" for i, (_, pat, _, _, _, _) in enumerate(candidates))
if not combined:
return text, whole
rng = random.Random(int(md5(text.encode("utf-8")).hexdigest()[:12], 16))
# Shortcut revolver: peek the same stored cycle generation will draw from (never saved
# here) so split boundaries are found in the text generation will actually see.
try:
_revolver = load_revolver()
except Exception:
_revolver = {"enabled": False, "random": False, "state": {}}
_rev_on = bool(_revolver.get("enabled"))
_rev_state = _revolver.get("state") if isinstance(_revolver.get("state"), dict) else {}
_rev_random = bool(_revolver.get("random"))
try:
pattern = re.compile(combined, re.IGNORECASE | re.UNICODE)
except re.error:
@@ -711,9 +722,13 @@ def _expand_with_map(text):
parts.append(lit); exp_pos += len(lit)
rep = None
pc_key, pc_name = "", ""
for i, (_, _, reps, sc_key, sc_name) in enumerate(candidates):
for i, (_, _, reps, sc_key, sc_name, db_key) in enumerate(candidates):
if m.group(f"t{i}") is not None:
rep = rng.choice(reps); pc_key, pc_name = sc_key, sc_name; break
if _rev_on and len(reps) > 1:
rep = revolver_next_replacement(_rev_state, db_key, reps, _rev_random)
else:
rep = rng.choice(reps)
pc_key, pc_name = sc_key, sc_name; break
if rep is None:
rep = m.group(0)
pieces.append({"exp_start": exp_pos, "exp_end": exp_pos + len(rep),
@@ -6661,13 +6676,13 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
def _v2_prompt_key(self, prompt):
return re.sub(r"\s+", " ", str(prompt or "").strip())
def _v2_apply_global_shortcuts(self, text, seed=0):
def _v2_apply_global_shortcuts(self, text, seed=0, revolver_commit=False):
try:
try:
from .templates import apply_prompt_shortcuts
except ImportError:
from templates import apply_prompt_shortcuts
expanded, applied = apply_prompt_shortcuts(text, seed=seed)
expanded, applied = apply_prompt_shortcuts(text, seed=seed, revolver_commit=revolver_commit)
except Exception as error:
return str(text or ""), f"Shortcuts: unavailable ({error}).", []
if not applied:
@@ -11729,8 +11744,13 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
# seeds to build N variant conditionings (Studio owns conditioning production).
_raw_positive_prompt = str(positive_prompt or "")
_raw_intent_prompt = str(user_intent_prompt or "")
positive_prompt, shortcut_status, shortcut_applied = self._v2_apply_global_shortcuts(positive_prompt, seed=seed)
# revolver_commit only on the real positive prompt: the one draw per run that advances
# the shortcut revolver's cycle. The intent prompt peeks, and does so FIRST — if it
# echoes the positive prompt's triggers, both then resolve to the same chamber (the
# same alignment the shared seed used to give them).
user_intent_prompt, intent_shortcut_status, intent_shortcut_applied = self._v2_apply_global_shortcuts(user_intent_prompt, seed=seed)
positive_prompt, shortcut_status, shortcut_applied = self._v2_apply_global_shortcuts(
positive_prompt, seed=seed, revolver_commit=True)
if intent_shortcut_applied:
shortcut_status = (
f"{shortcut_status}\nIntent {intent_shortcut_status}"
@@ -12864,14 +12884,31 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
return (cond_i, m)
try:
base_expansion = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed)[0]
try:
from .templates import revolver_enabled as _revolver_enabled
except ImportError:
from templates import revolver_enabled as _revolver_enabled
revolver_on = bool(_revolver_enabled())
except Exception:
base_expansion = str(raw_prompt or "")
revolver_on = False
out = [tagged(base_entry, 0, base_prompt)]
cache = {base_expansion: base_entry}
if revolver_on:
# Shortcut revolver: each variant draw below COMMITS and advances the shared
# cycle, so N variants sweep N consecutive chambers (no repeats until the pool
# exhausts). Re-expanding the base here would both burn a chamber and collide
# with variant 1's draw — skip seeding the dedupe cache with it.
base_expansion = str(raw_prompt or "")
cache = {}
else:
try:
base_expansion = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed)[0]
except Exception:
base_expansion = str(raw_prompt or "")
cache = {base_expansion: base_entry}
for i in range(1, n):
try:
exp_i = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed + i)[0]
exp_i = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed + i,
revolver_commit=True)[0]
except Exception:
exp_i = base_expansion
entry = cache.get(exp_i)
@@ -14387,6 +14424,23 @@ class FunPackStudio:
},
}
@classmethod
def IS_CHANGED(cls, **kwargs):
# Shortcut revolver advances persistent per-shortcut state on every run, so a
# fixed-seed re-run must not be served from ComfyUI's cache while it's enabled —
# the next Generate is supposed to draw the next replacement. Normal input-hash
# caching applies when the revolver is off.
try:
try:
from .templates import revolver_enabled
except ImportError:
from templates import revolver_enabled
if revolver_enabled():
return float("nan")
except Exception:
pass
return ""
@staticmethod
def _is_output_connected(prompt, unique_id, output_index):
return FunPackVideoRefiner()._is_output_connected(prompt, unique_id, output_index)
+14
View File
@@ -285,6 +285,20 @@ def delete_shortcut(name: str) -> dict:
return {"shortcuts": si(), "categories": _shortcut_categories()}
def revolver_settings() -> dict:
"""Shortcut-revolver settings (enabled + random order). State lives server-side next to
the shortcut DB so previews and generation share one cycle."""
return _funpack_attr("templates", "revolver_settings")()
def set_revolver_settings(payload: dict) -> dict:
fn = _funpack_attr("templates", "set_revolver_settings")
return fn(
enabled=payload.get("enabled"),
random_order=payload.get("random"),
)
def save_category(payload: dict) -> dict:
"""Add a category (and optionally a sub-category under it) to the managed list."""
add = _funpack_attr("templates", "add_shortcut_category")
+2
View File
@@ -89,6 +89,8 @@
exportShortcutsUrl: () => API("/library/shortcuts/export"),
importShortcuts: (data, mode) => j("POST", API(`/library/shortcuts/import?mode=${mode || "merge"}`), data),
clearShortcuts: () => j("POST", API("/library/shortcuts/clear"), {}),
revolverSettings: () => j("GET", API("/library/revolver")),
setRevolverSettings: (payload) => j("POST", API("/library/revolver"), payload),
// FunPack file manager (Composer ▸ Files)
listFiles: () => j("GET", API("/files")),
+51 -1
View File
@@ -141,6 +141,55 @@
return wrap;
}
// Shortcut revolver: server-side library setting (not per-browser) — the no-repeat cycle
// is shared by previews and generation, so the toggle lives with the shortcut DB.
function revolverSection() {
const wrap = el("div", "es-section");
wrap.append(el("div", "es-section-title", "Shortcut revolver"));
wrap.append(el("div", "es-hint",
"For shortcuts with several replacements: remember which replacement fired and don’t reuse it "
+ "until the WHOLE set has fired. Off = seeded random pick (may repeat). Saved with your shortcut "
+ "library — applies to every browser and to generation."));
const main = el("label", "chk es-toggle");
const mainCb = el("input"); mainCb.type = "checkbox"; mainCb.disabled = true;
main.append(mainCb, el("span", null, "Shortcut revolver"));
wrap.append(main);
const sub = el("div", "es-subfields");
const rand = el("label", "chk es-toggle");
const randCb = el("input"); randCb.type = "checkbox"; randCb.disabled = true;
rand.append(randCb, el("span", null, "Random"));
sub.append(rand);
sub.append(el("div", "es-hint",
"Unchecked: replacements fire in order — first, second, … last, then the cycle restarts. "
+ "Checked: any order, but still no repeats until the set is exhausted. Changing either "
+ "option restarts all cycles."));
wrap.append(sub);
const apply = (s) => {
mainCb.checked = !!(s && s.enabled);
randCb.checked = !!(s && s.random);
mainCb.disabled = false;
randCb.disabled = !mainCb.checked;
sub.style.display = mainCb.checked ? "" : "none";
};
const save = () => {
mainCb.disabled = randCb.disabled = true;
API.setRevolverSettings({ enabled: mainCb.checked, random: randCb.checked })
.then(apply)
.catch(() => { API.revolverSettings().then(apply).catch(() => {}); });
};
mainCb.onchange = save;
randCb.onchange = save;
sub.style.display = "none";
API.revolverSettings().then(apply).catch(() => {
wrap.append(el("div", "es-hint", "Could not load revolver settings."));
});
return wrap;
}
function toggleRow(label, hint, key) {
const row = el("div", "es-row");
const lbl = el("label", "chk es-toggle");
@@ -166,6 +215,7 @@
"Use anchor",
"Text before the first split trigger is a shared anchor prepended to every scene. Turn off to make that leading text Scene 1 instead.",
"anchorEnabled"));
content.append(revolverSection());
content.append(anchorGuideSection());
body.append(content);
}
@@ -173,7 +223,7 @@
window.SettingsWindow.register({
id: "editor", group: "", title: "Editor",
subtitle: "Per-browser preferences — they apply to this browser, not the project.",
keywords: "autocomplete anchor prompt shortcuts ideas suggestions i2v bypass guide preferences",
keywords: "autocomplete anchor prompt shortcuts ideas suggestions i2v bypass guide preferences revolver random replacements cycle",
iconBg: "linear-gradient(180deg,#6aa9ff,#3b6fd9)",
icon: '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round"><path d="M2 4.5h12M2 8h12M2 11.5h12"/><circle cx="6" cy="4.5" r="1.7" fill="#fff" stroke="none"/><circle cx="11" cy="8" r="1.7" fill="#fff" stroke="none"/><circle cx="5" cy="11.5" r="1.7" fill="#fff" stroke="none"/></svg>',
mount,
+14
View File
@@ -1338,6 +1338,20 @@ if web is not None and PromptServer is not None:
except Exception as e: # noqa: BLE001
raise web.HTTPBadRequest(reason=str(e))
@routes.get(UI_PREFIX + "/api/library/revolver")
async def _revolver_get(_req):
try:
return web.json_response(bridge.revolver_settings())
except Exception as e: # noqa: BLE001
return web.json_response({"enabled": False, "random": False, "error": str(e)})
@routes.post(UI_PREFIX + "/api/library/revolver")
async def _revolver_set(req):
try:
return web.json_response(bridge.set_revolver_settings(await req.json()))
except Exception as e: # noqa: BLE001
raise web.HTTPBadRequest(reason=str(e))
@routes.post(UI_PREFIX + "/api/library/categories")
async def _category_save(req):
try:
+122 -7
View File
@@ -548,6 +548,101 @@ def save_shortcut_db(data):
json.dump(data, file, indent=2, sort_keys=True)
REVOLVER_STORE_FILENAME = "shortcut_revolver.json"
def revolver_store_path():
return os.path.join(template_store_dir(), REVOLVER_STORE_FILENAME)
def load_revolver():
"""Shortcut-revolver settings + per-shortcut cycle state. Sidecar of the shortcut DB —
deliberately NOT part of the shortcuts export/import payload. ``state[<shortcut key>]`` =
{"fp": <fingerprint of the replacement list>, "queue": [remaining replacement indices,
next one first]}."""
data = {}
path = revolver_store_path()
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
except (json.JSONDecodeError, OSError, ValueError):
data = {}
if not isinstance(data, dict):
data = {}
state = data.get("state")
return {
"enabled": bool(data.get("enabled", False)),
"random": bool(data.get("random", False)),
"state": state if isinstance(state, dict) else {},
}
def save_revolver(data):
os.makedirs(template_store_dir(), exist_ok=True)
with open(revolver_store_path(), "w", encoding="utf-8") as file:
json.dump({
"enabled": bool(data.get("enabled", False)),
"random": bool(data.get("random", False)),
"state": data.get("state") if isinstance(data.get("state"), dict) else {},
}, file, indent=2)
def revolver_enabled():
try:
return bool(load_revolver()["enabled"])
except Exception:
return False
def revolver_settings():
data = load_revolver()
return {"enabled": data["enabled"], "random": data["random"]}
def set_revolver_settings(enabled=None, random_order=None):
"""Update revolver settings. Any actual change resets the cycle state — the old queues'
ordering semantics no longer apply once the mode flips."""
data = load_revolver()
changed = False
if enabled is not None and bool(enabled) != data["enabled"]:
data["enabled"] = bool(enabled)
changed = True
if random_order is not None and bool(random_order) != data["random"]:
data["random"] = bool(random_order)
changed = True
if changed:
data["state"] = {}
save_revolver(data)
return {"enabled": data["enabled"], "random": data["random"]}
def _revolver_fingerprint(replacements):
payload = json.dumps(list(replacements), ensure_ascii=False)
return md5(payload.encode("utf-8")).hexdigest()[:12]
def revolver_next_replacement(state, key, replacements, random_order):
"""Draw the next replacement for shortcut ``key`` from its revolver queue, mutating
``state`` in place. The queue is rebuilt — a fresh full cycle over every replacement —
when it's empty or the replacement set changed (fingerprint mismatch): sequential
(first, second, … last) by default, shuffled once per cycle when ``random_order``.
Either way nothing repeats until the whole set has fired."""
n = len(replacements)
fp = _revolver_fingerprint(replacements)
entry = state.get(key)
queue = None
if isinstance(entry, dict) and entry.get("fp") == fp and isinstance(entry.get("queue"), list):
queue = [int(i) for i in entry["queue"] if isinstance(i, int) and 0 <= int(i) < n]
if not queue:
queue = list(range(n))
if random_order:
random.shuffle(queue)
index = queue.pop(0)
state[key] = {"fp": fp, "queue": queue}
return replacements[index]
def shortcut_items(data=None):
data = load_shortcut_db() if data is None else normalize_shortcut_db(data)
items = []
@@ -827,13 +922,18 @@ def resolve_variables(text, variables):
return _expand(str(text or ""), frozenset(), 0), sorted(undefined)
def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
def apply_prompt_shortcuts(text, seed=0, shortcut_db=None, revolver_commit=False):
"""Expand shortcut triggers. Multi-replacement picks are seeded-random unless the
shortcut revolver is enabled (see load_revolver): then each firing draws the next
replacement from the shortcut's no-repeat cycle. ``revolver_commit`` persists the
advanced cycle state — True only for real generation; previews peek without saving,
so they show exactly what the next generation will draw."""
original = str(text or "")
if not original:
return original, []
data = load_shortcut_db() if shortcut_db is None else normalize_shortcut_db(shortcut_db)
candidates = []
for shortcut in data.get("shortcuts", {}).values():
for db_key, shortcut in data.get("shortcuts", {}).items():
if not isinstance(shortcut, dict) or not bool(shortcut.get("enabled", True)):
continue
replacements = _shortcut_replacements(shortcut.get("replacements", shortcut.get("replacement", [])))
@@ -843,12 +943,13 @@ def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
for trigger in shortcut_list(shortcut.get("triggers", [])):
pattern = _shortcut_trigger_pattern(trigger)
if pattern:
candidates.append((trigger, pattern, replacements, shortcut.get("name", trigger), sc_key))
candidates.append((trigger, pattern, replacements, shortcut.get("name", trigger),
sc_key, str(db_key)))
if not candidates:
return original, []
candidates.sort(key=lambda item: len(shortcut_key(item[0])), reverse=True)
combined = "|".join(f"(?P<t{index}>{pattern})" for index, (_, pattern, _, _, _) in enumerate(candidates))
combined = "|".join(f"(?P<t{index}>{pattern})" for index, (_, pattern, _, _, _, _) in enumerate(candidates))
if not combined:
return original, []
try:
@@ -858,15 +959,24 @@ def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
if rng_seed == 0:
rng_seed = int(md5(original.encode("utf-8")).hexdigest()[:12], 16)
rng = random.Random(rng_seed)
revolver = load_revolver()
revolver_on = bool(revolver.get("enabled"))
revolver_state = revolver.get("state")
revolver_random = bool(revolver.get("random"))
revolver_dirty = False
applied = []
removals_happened = False
def replace(match):
nonlocal removals_happened
for index, (trigger, _, replacements, name, sc_key) in enumerate(candidates):
nonlocal removals_happened, revolver_dirty
for index, (trigger, _, replacements, name, sc_key, db_key) in enumerate(candidates):
if match.group(f"t{index}") is None:
continue
replacement = rng.choice(replacements)
if revolver_on and len(replacements) > 1:
replacement = revolver_next_replacement(revolver_state, db_key, replacements, revolver_random)
revolver_dirty = True
else:
replacement = rng.choice(replacements)
applied.append({"name": str(name), "trigger": trigger,
"replacement": replacement, "refinement_key": sc_key})
if not replacement:
@@ -877,6 +987,11 @@ def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
expanded = re.sub(combined, replace, original, flags=re.IGNORECASE | re.UNICODE)
if removals_happened:
expanded = _cleanup_removed_phrases(expanded)
if revolver_dirty and revolver_commit:
try:
save_revolver(revolver)
except OSError:
pass
return expanded, applied
+140
View File
@@ -0,0 +1,140 @@
"""Shortcut revolver: no-repeat cycling through a shortcut's replacements.
Covers: sequential cycle order + wrap-around, random mode as a full no-repeat permutation
per cycle, peek (previews) vs commit (generation) semantics on apply_prompt_shortcuts,
fingerprint reset when the replacement set is edited, settings changes resetting state,
and off-by-default behavior staying byte-identical to the seeded random pick.
"""
import sys
import types
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
class _FakeRoutes:
def get(self, _p):
return lambda f: f
def post(self, _p):
return lambda f: f
def put(self, _p):
return lambda f: f
def delete(self, _p):
return lambda f: f
def patch(self, _p):
return lambda f: f
sys.modules.setdefault("folder_paths", types.SimpleNamespace(models_dir="", get_user_directory=lambda: ""))
sys.modules.setdefault(
"server",
types.SimpleNamespace(PromptServer=types.SimpleNamespace(instance=types.SimpleNamespace(routes=_FakeRoutes()))),
)
import templates
from templates import (
apply_prompt_shortcuts,
load_revolver,
normalize_shortcut_item,
revolver_next_replacement,
save_revolver,
set_revolver_settings,
)
REPS = ["alpha one", "alpha two", "alpha three"]
def _db():
item = normalize_shortcut_item({
"name": "Alpha", "triggers": ["aa"], "replacements": list(REPS), "refinement_key": "",
})
single = normalize_shortcut_item({
"name": "Solo", "triggers": ["ss"], "replacements": ["only one"], "refinement_key": "",
})
return {"version": 1, "source": "test",
"shortcuts": {templates.shortcut_key("Alpha"): item,
templates.shortcut_key("Solo"): single}}
def _use_tmp_store(monkeypatch, tmp_path):
monkeypatch.setattr(templates, "template_store_dir", lambda: str(tmp_path))
def test_sequential_cycle_then_wraps():
state = {}
draws = [revolver_next_replacement(state, "k", REPS, False) for _ in range(7)]
assert draws == REPS + REPS + [REPS[0]]
def test_random_cycle_is_full_permutation_without_repeats():
state = {}
for _ in range(20): # many cycles: each must be a permutation of the full set
cycle = [revolver_next_replacement(state, "k", REPS, True) for _ in range(len(REPS))]
assert sorted(cycle) == sorted(REPS)
def test_replacement_edit_resets_cycle():
state = {}
revolver_next_replacement(state, "k", REPS, False)
edited = REPS + ["alpha four"]
assert revolver_next_replacement(state, "k", edited, False) == edited[0]
def test_apply_shortcuts_peek_does_not_advance(monkeypatch, tmp_path):
_use_tmp_store(monkeypatch, tmp_path)
save_revolver({"enabled": True, "random": False, "state": {}})
first, _ = apply_prompt_shortcuts("an aa here", seed=1, shortcut_db=_db())
second, _ = apply_prompt_shortcuts("an aa here", seed=2, shortcut_db=_db())
assert first == second == f"an {REPS[0]} here"
def test_apply_shortcuts_commit_advances_and_wraps(monkeypatch, tmp_path):
_use_tmp_store(monkeypatch, tmp_path)
save_revolver({"enabled": True, "random": False, "state": {}})
seen = [apply_prompt_shortcuts("an aa here", seed=1, shortcut_db=_db(),
revolver_commit=True)[0] for _ in range(4)]
assert seen == [f"an {r} here" for r in REPS + [REPS[0]]]
def test_multiple_firings_in_one_prompt_consume_consecutive_chambers(monkeypatch, tmp_path):
_use_tmp_store(monkeypatch, tmp_path)
save_revolver({"enabled": True, "random": False, "state": {}})
expanded, applied = apply_prompt_shortcuts("aa then aa", seed=1, shortcut_db=_db(),
revolver_commit=True)
assert expanded == f"{REPS[0]} then {REPS[1]}"
assert [a["replacement"] for a in applied] == [REPS[0], REPS[1]]
def test_single_replacement_shortcut_untouched_by_revolver(monkeypatch, tmp_path):
_use_tmp_store(monkeypatch, tmp_path)
save_revolver({"enabled": True, "random": False, "state": {}})
expanded, _ = apply_prompt_shortcuts("a ss here", seed=1, shortcut_db=_db(),
revolver_commit=True)
assert expanded == "a only one here"
assert load_revolver()["state"] == {} # nothing multi-replacement fired -> no state written
def test_disabled_matches_seeded_random(monkeypatch, tmp_path):
_use_tmp_store(monkeypatch, tmp_path)
a, _ = apply_prompt_shortcuts("an aa here", seed=7, shortcut_db=_db())
b, _ = apply_prompt_shortcuts("an aa here", seed=7, shortcut_db=_db(), revolver_commit=True)
assert a == b # same seed -> same pick; no revolver file involved
assert not (tmp_path / templates.REVOLVER_STORE_FILENAME).exists()
def test_settings_change_resets_state(monkeypatch, tmp_path):
_use_tmp_store(monkeypatch, tmp_path)
save_revolver({"enabled": True, "random": False,
"state": {"x": {"fp": "abc", "queue": [2]}}})
settings = set_revolver_settings(random_order=True)
assert settings == {"enabled": True, "random": True}
assert load_revolver()["state"] == {}
# No-op update keeps state.
save_revolver({"enabled": True, "random": True,
"state": {"x": {"fp": "abc", "queue": [2]}}})
set_revolver_settings(enabled=True, random_order=True)
assert load_revolver()["state"] != {}