feat(scenes): add scene fragment library mirroring characters

Extracts the per-name YAML-list storage logic out of tensors/characters.py
into a new generic tensors.fragments.FragmentLibrary class, then layers two
kind-specific function-style modules on top:

- tensors.characters (unchanged public API, now thin wrapper)
- tensors.scenes (new): save_scene / load_scene / list_scenes / delete_scene /
  scene_path / resolve_scene, stored in ~/.local/share/tensors/scenes/

CLI additions:
- tsr scene save -o <name> 'elem1, elem2, ...'
- tsr scene list / show / delete

Wired into generate + template + style-sweep:
- tsr generate -S <name> / --scene-prompt 'elems' injects scene elements
  into the positive prompt right after the character block (order:
  quality_prefix -> character -> scene -> rating -> user)
- tsr template -S <name> / --scene-prompt 'elems' embeds a 'scene' list
  field in the dumped JSON template (named + inline merged, deduped)
- style-sweep templates accept 'scene' (str|list) and 'scene_prompt' keys
- generate --input JSON honors both 'scene' (str=name or list=inline)
  and 'scene_prompt' keys

FragmentLibrary uses kind-aware error messages (e.g. "Invalid scene name"
vs "Invalid character name") so users see the right context.

28 new tests cover the generic library + scenes. Bumps version to 0.1.24
(supersedes v0.1.23 which failed to publish due to a transient Sigstore
network error).
This commit is contained in:
2026-05-18 02:19:01 +02:00
parent a0524cbf5e
commit ad31341a4d
7 changed files with 725 additions and 149 deletions
+91
View File
@@ -0,0 +1,91 @@
"""Tests for the generic FragmentLibrary in tensors.fragments.
The character/scene-specific modules are thin wrappers over this class; their
behavioral coverage lives in test_characters.py and test_scenes.py. Here we
focus on cross-kind invariants (kind-aware error messages, name validation,
parse helper).
"""
from __future__ import annotations
import pytest
@pytest.fixture
def lib_factory(tmp_path):
"""Return a callable that builds an isolated FragmentLibrary per kind."""
from tensors.fragments import FragmentLibrary
def _make(kind: str = "characters"):
return FragmentLibrary(kind, base_dir=tmp_path / kind)
return _make
class TestParseElements:
def test_splits_trims_and_dedupes(self):
from tensors.fragments import parse_elements
assert parse_elements("a, b , a, c, b") == ["a", "b", "c"]
def test_empty_returns_empty(self):
from tensors.fragments import parse_elements
assert parse_elements("") == []
assert parse_elements(" , , ") == []
class TestKindAwareMessages:
def test_singular_in_error_for_plural_kind(self, lib_factory):
lib = lib_factory("scenes")
with pytest.raises(ValueError, match=r"Invalid scene name"):
lib.save("bad name", ["x"])
def test_singular_in_error_for_already_singular_kind(self, lib_factory):
lib = lib_factory("style") # already singular — should NOT strip the 'e'
with pytest.raises(ValueError, match=r"Invalid style name"):
lib.save("bad name", ["x"])
def test_load_missing_uses_kind_singular(self, lib_factory):
lib = lib_factory("scenes")
with pytest.raises(FileNotFoundError, match=r"Scene 'ghost' not found"):
lib.load("ghost")
class TestLibraryConstructor:
def test_rejects_invalid_kind(self):
from tensors.fragments import FragmentLibrary
with pytest.raises(ValueError, match=r"Invalid library kind"):
FragmentLibrary("bad kind")
def test_accepts_dotted_alphanumeric_kind(self):
from tensors.fragments import FragmentLibrary
lib = FragmentLibrary("custom_v2.beta")
assert lib.kind == "custom_v2.beta"
class TestResolve:
def test_only_named(self, lib_factory):
lib = lib_factory()
lib.save("base", ["x", "y"])
assert lib.resolve(name="base") == ["x", "y"]
def test_only_inline(self, lib_factory):
lib = lib_factory()
assert lib.resolve(inline="a, b, c") == ["a", "b", "c"]
def test_named_inline_extras_merge_deduped_in_order(self, lib_factory):
lib = lib_factory()
lib.save("base", ["a", "b"])
assert lib.resolve(name="base", inline="b, c", extra=["c", "d"]) == ["a", "b", "c", "d"]
def test_no_args_empty(self, lib_factory):
lib = lib_factory()
assert lib.resolve() == []
def test_named_missing_raises(self, lib_factory):
lib = lib_factory()
with pytest.raises(FileNotFoundError):
lib.resolve(name="absent")
+96
View File
@@ -0,0 +1,96 @@
"""Tests for the scene library (tensors.scenes).
Mirrors test_characters.py — the underlying implementation is shared so the
focus here is verifying that scene-specific exports route through correctly
and that monkeypatching SCENES_DIR works the same way.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def scene_env(tmp_path, monkeypatch):
"""Redirect SCENES_DIR at fresh tmp_path per test."""
from tensors import scenes as scene_mod
scene_dir = tmp_path / "scenes"
monkeypatch.setattr(scene_mod, "SCENES_DIR", scene_dir)
return scene_mod, scene_dir
class TestSaveLoad:
def test_save_creates_dir_and_file(self, scene_env):
scene_mod, scene_dir = scene_env
path = scene_mod.save_scene("penthouse", ["luxury penthouse", "volumetric lighting"])
assert path == scene_dir / "penthouse.yml"
assert path.is_file()
assert path.read_text() == '- "luxury penthouse"\n- "volumetric lighting"\n'
def test_load_roundtrip(self, scene_env):
scene_mod, _ = scene_env
elements = ["a", "b with spaces", "c, with, commas", 'd "quotes"']
scene_mod.save_scene("mixed", elements)
assert scene_mod.load_scene("mixed") == elements
def test_load_missing_raises(self, scene_env):
scene_mod, _ = scene_env
with pytest.raises(FileNotFoundError, match=r"Scene 'nope' not found"):
scene_mod.load_scene("nope")
class TestListAndDelete:
def test_list_empty_when_dir_missing(self, scene_env):
scene_mod, _ = scene_env
assert scene_mod.list_scenes() == []
def test_list_sorted_names(self, scene_env):
scene_mod, _ = scene_env
scene_mod.save_scene("zeta", ["x"])
scene_mod.save_scene("alpha", ["y"])
assert scene_mod.list_scenes() == ["alpha", "zeta"]
def test_delete_existing(self, scene_env):
scene_mod, _ = scene_env
scene_mod.save_scene("doomed", ["x"])
assert scene_mod.delete_scene("doomed") is True
assert scene_mod.list_scenes() == []
def test_delete_missing(self, scene_env):
scene_mod, _ = scene_env
assert scene_mod.delete_scene("ghost") is False
class TestResolveScene:
def test_named_plus_inline_dedup(self, scene_env):
scene_mod, _ = scene_env
scene_mod.save_scene("base", ["a", "b"])
assert scene_mod.resolve_scene(scene="base", scene_prompt="b, c") == ["a", "b", "c"]
def test_extra_appends_last(self, scene_env):
scene_mod, _ = scene_env
assert scene_mod.resolve_scene(scene_prompt="a, b", extra=["b", "c"]) == ["a", "b", "c"]
def test_no_args_empty(self, scene_env):
scene_mod, _ = scene_env
assert scene_mod.resolve_scene() == []
class TestNameValidation:
@pytest.mark.parametrize("bad_name", ["", "foo/bar", "../etc", "with space", "a$b"])
def test_invalid_names_rejected(self, scene_env, bad_name):
scene_mod, _ = scene_env
with pytest.raises(ValueError, match=r"Invalid scene name"):
scene_mod.save_scene(bad_name, ["x"])
def test_scene_path_validates(self, scene_env):
scene_mod, _ = scene_env
with pytest.raises(ValueError, match=r"Invalid scene name"):
scene_mod.scene_path("bad name")