From 6528acf504184f1b1ad6de91eb14ea709c8796cd Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Mon, 6 Jul 2026 05:26:21 +0300 Subject: [PATCH] Shortcut revolver: no-repeat cycling through multi-replacement shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- conditioning.py | 82 ++++++++++--- movie_editor/backend/bridge.py | 14 +++ movie_editor/frontend/api.js | 2 + movie_editor/frontend/editor_settings.js | 52 ++++++++- movie_editor/server.py | 14 +++ templates.py | 129 +++++++++++++++++++-- tests/test_shortcut_revolver.py | 140 +++++++++++++++++++++++ 7 files changed, 411 insertions(+), 22 deletions(-) create mode 100644 tests/test_shortcut_revolver.py diff --git a/conditioning.py b/conditioning.py index d16165fb..3cf89fc4 100644 --- a/conditioning.py +++ b/conditioning.py @@ -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{pat})" for i, (_, pat, _, _, _) in enumerate(candidates)) + combined = "|".join(f"(?P{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) diff --git a/movie_editor/backend/bridge.py b/movie_editor/backend/bridge.py index 249ed3a3..6c2f342b 100644 --- a/movie_editor/backend/bridge.py +++ b/movie_editor/backend/bridge.py @@ -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") diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index 22be7d99..054ae761 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -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")), diff --git a/movie_editor/frontend/editor_settings.js b/movie_editor/frontend/editor_settings.js index b77102a8..cdb72094 100644 --- a/movie_editor/frontend/editor_settings.js +++ b/movie_editor/frontend/editor_settings.js @@ -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: '', mount, diff --git a/movie_editor/server.py b/movie_editor/server.py index 284f4544..4d8c6f06 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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: diff --git a/templates.py b/templates.py index f7261147..6641d69c 100644 --- a/templates.py +++ b/templates.py @@ -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[]`` = + {"fp": , "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{pattern})" for index, (_, pattern, _, _, _) in enumerate(candidates)) + combined = "|".join(f"(?P{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 diff --git a/tests/test_shortcut_revolver.py b/tests/test_shortcut_revolver.py new file mode 100644 index 00000000..49cc891e --- /dev/null +++ b/tests/test_shortcut_revolver.py @@ -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"] != {}