Files
tensors/tests/test_characters.py
aladac 74d68261df feat(characters): named prompt-fragment library + --character/--character-prompt
Adds a 'character' subsystem for reusable prompt fragments stored as YAML
lists in ~/.local/share/tensors/characters/<name>.yml.

New tensors.characters module:
- save_character / load_character / list_characters / delete_character
- parse_elements + resolve_character (named + inline merge with dedup)
- Path-traversal-safe name validation ([A-Za-z0-9_.-]+)
- JSON-encoded YAML scalars on write; tolerant reader (JSON, single-quoted YAML, bare)

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

Wired into generate + template + style-sweep:
- tsr generate -C <name> / --character-prompt 'elems' injects character
  elements into the positive prompt (after quality_prefix, before user prompt)
- tsr template -C <name> / --character-prompt 'elems' embeds a 'character'
  list field in the dumped JSON template (named + inline merged, deduped)
- style-sweep templates accept 'character' (str|list) and 'character_prompt'
  keys; lists are passed through verbatim, names are looked up at run-time
- generate --input JSON honors both 'character' (str=name or list=inline)
  and 'character_prompt' keys

37 new tests cover module behavior. Bumps version to 0.1.23.
2026-05-18 02:12:15 +02:00

195 lines
6.7 KiB
Python

"""Tests for the character library (tensors.characters)."""
from __future__ import annotations
import pytest
@pytest.fixture
def char_env(tmp_path, monkeypatch):
"""Redirect CHARACTERS_DIR (and DATA_DIR) at fresh tmp_path per test.
The module captures CHARACTERS_DIR at import time, so we monkeypatch the
attribute directly rather than rely on env vars (config.DATA_DIR is also
captured at import time).
"""
from tensors import characters as char_mod
char_dir = tmp_path / "characters"
monkeypatch.setattr(char_mod, "CHARACTERS_DIR", char_dir)
return char_mod, char_dir
class TestParseElements:
def test_splits_on_commas_and_trims(self):
from tensors.characters import parse_elements
assert parse_elements("a, b , c") == ["a", "b", "c"]
def test_drops_empty_and_duplicates(self):
from tensors.characters import parse_elements
assert parse_elements("a, , a, b, b , c") == ["a", "b", "c"]
def test_preserves_internal_spacing(self):
from tensors.characters import parse_elements
assert parse_elements("blond hair, blue eyes") == ["blond hair", "blue eyes"]
def test_empty_input(self):
from tensors.characters import parse_elements
assert parse_elements("") == []
assert parse_elements(" , , ") == []
class TestSaveLoad:
def test_save_creates_dir_and_file(self, char_env):
char_mod, char_dir = char_env
path = char_mod.save_character("cassie", ["blond hair", "broad chin"])
assert path == char_dir / "cassie.yml"
assert path.is_file()
assert path.read_text() == '- "blond hair"\n- "broad chin"\n'
def test_load_roundtrip(self, char_env):
char_mod, _ = char_env
elements = ["a", "b with spaces", "c, with, commas", 'd "quotes"']
char_mod.save_character("mixed", elements)
assert char_mod.load_character("mixed") == elements
def test_load_missing_raises(self, char_env):
char_mod, _ = char_env
with pytest.raises(FileNotFoundError):
char_mod.load_character("nope")
def test_load_tolerates_hand_edited_single_quoted_yaml(self, char_env):
char_mod, char_dir = char_env
char_dir.mkdir(parents=True)
(char_dir / "manual.yml").write_text("- 'foo'\n- 'it''s'\n- bare\n# comment\n\n")
assert char_mod.load_character("manual") == ["foo", "it's", "bare"]
def test_save_overwrites(self, char_env):
char_mod, _ = char_env
char_mod.save_character("c", ["a"])
char_mod.save_character("c", ["b", "c"])
assert char_mod.load_character("c") == ["b", "c"]
def test_save_empty_list_writes_empty_file(self, char_env):
char_mod, _char_dir = char_env
path = char_mod.save_character("empty", [])
assert path.is_file()
assert path.read_text() == ""
assert char_mod.load_character("empty") == []
class TestListAndDelete:
def test_list_empty_when_dir_missing(self, char_env):
char_mod, _ = char_env
assert char_mod.list_characters() == []
def test_list_sorted_names(self, char_env):
char_mod, _ = char_env
char_mod.save_character("zeta", ["x"])
char_mod.save_character("alpha", ["y"])
char_mod.save_character("mu", ["z"])
assert char_mod.list_characters() == ["alpha", "mu", "zeta"]
def test_delete_existing_returns_true(self, char_env):
char_mod, _ = char_env
char_mod.save_character("doomed", ["x"])
assert char_mod.delete_character("doomed") is True
assert char_mod.list_characters() == []
def test_delete_missing_returns_false(self, char_env):
char_mod, _ = char_env
assert char_mod.delete_character("ghost") is False
class TestNameValidation:
@pytest.mark.parametrize("bad_name", ["", "foo/bar", "../etc", "with space", "name.yml/extra", "a$b"])
def test_invalid_names_rejected(self, char_env, bad_name):
char_mod, _ = char_env
with pytest.raises(ValueError, match=r"Invalid character name"):
char_mod.save_character(bad_name, ["x"])
@pytest.mark.parametrize("good_name", ["a", "foo_bar", "foo-bar", "foo.bar", "Mixed123", "v1.2_alpha-beta"])
def test_valid_names_accepted(self, char_env, good_name):
char_mod, _ = char_env
path = char_mod.save_character(good_name, ["x"])
assert path.is_file()
def test_load_validates_name(self, char_env):
char_mod, _ = char_env
with pytest.raises(ValueError, match=r"Invalid character name"):
char_mod.load_character("../escape")
def test_delete_validates_name(self, char_env):
char_mod, _ = char_env
with pytest.raises(ValueError, match=r"Invalid character name"):
char_mod.delete_character("../escape")
class TestResolveCharacter:
def test_named_only(self, char_env):
char_mod, _ = char_env
char_mod.save_character("base", ["x", "y"])
assert char_mod.resolve_character(character="base") == ["x", "y"]
def test_inline_only(self, char_env):
char_mod, _ = char_env
assert char_mod.resolve_character(character_prompt="a, b, c") == ["a", "b", "c"]
def test_extra_only(self, char_env):
char_mod, _ = char_env
assert char_mod.resolve_character(extra=["a", "b"]) == ["a", "b"]
def test_named_plus_inline_dedup_preserves_order(self, char_env):
char_mod, _ = char_env
char_mod.save_character("base", ["a", "b"])
# 'b' is shared — should appear once, in named position
assert char_mod.resolve_character(character="base", character_prompt="b, c") == ["a", "b", "c"]
def test_all_three_sources_merged(self, char_env):
char_mod, _ = char_env
char_mod.save_character("base", ["a", "b"])
result = char_mod.resolve_character(
character="base",
character_prompt="c, d",
extra=["d", "e"],
)
assert result == ["a", "b", "c", "d", "e"]
def test_no_args_returns_empty(self, char_env):
char_mod, _ = char_env
assert char_mod.resolve_character() == []
def test_missing_named_raises(self, char_env):
char_mod, _ = char_env
with pytest.raises(FileNotFoundError):
char_mod.resolve_character(character="absent")
class TestCharacterPath:
def test_path_does_not_require_existence(self, char_env):
char_mod, char_dir = char_env
# character_path is pure — doesn't touch disk
assert char_mod.character_path("ghost") == char_dir / "ghost.yml"
def test_path_validates_name(self, char_env):
char_mod, _ = char_env
with pytest.raises(ValueError):
char_mod.character_path("bad name")