A 5e storytelling engine with an LLM DM
1"""Tests for the CultureForge and high-level Generator."""
2
3from pathlib import Path
4
5import pytest
6
7from storied.names.forge import CultureForge, ForgedCulture
8from storied.names.generator import Generator
9
10
11@pytest.fixture
12def world_dir(tmp_path: Path) -> Path:
13 """A fresh world directory the forge can write to."""
14 return tmp_path / "test-world"
15
16
17class TestCultureForge:
18 def test_forge_writes_yaml_file(self, world_dir: Path):
19 forge = CultureForge(world_path=world_dir)
20 culture = forge.forge(seed=42)
21 path = world_dir / "cultures" / f"{culture.name}.yaml"
22 assert path.exists()
23
24 def test_forge_returns_self_named_culture(self, world_dir: Path):
25 forge = CultureForge(world_path=world_dir)
26 culture = forge.forge(seed=42)
27 assert culture.name
28 assert culture.name.isalpha()
29 assert culture.name == culture.name.lower()
30
31 def test_forge_self_name_in_length_window(self, world_dir: Path):
32 forge = CultureForge(world_path=world_dir)
33 for seed in range(10):
34 culture = forge.forge(seed=seed * 100)
35 assert 4 <= len(culture.name) <= 9, (
36 f"Culture name {culture.name!r} out of range"
37 )
38
39 def test_forge_is_deterministic(self, tmp_path: Path):
40 forge1 = CultureForge(world_path=tmp_path / "a")
41 forge2 = CultureForge(world_path=tmp_path / "b")
42 c1 = forge1.forge(seed=12345)
43 c2 = forge2.forge(seed=12345)
44 assert c1.name == c2.name
45
46 def test_forge_different_seeds_yield_different_cultures(
47 self,
48 world_dir: Path,
49 ):
50 forge = CultureForge(world_path=world_dir)
51 cultures = [forge.forge(seed=s) for s in range(10)]
52 names = {c.name for c in cultures}
53 assert len(names) >= 9 # tolerate one rare collision
54
55 def test_forge_with_feel_picks_matching_inventory(self, world_dir: Path):
56 # Forge several coastal cultures and check they came from
57 # inventories tagged "coastal" in the data file.
58 coastal_inventories = {
59 "welsh",
60 "old-norse",
61 "polynesian",
62 "austronesian",
63 "iberian",
64 "japonic",
65 "arabic-port",
66 "yoruboid",
67 "hellenic",
68 "cornish",
69 }
70 for seed in range(5):
71 forge2 = CultureForge(world_path=world_dir.parent / f"w{seed}")
72 culture = forge2.forge(feel="coastal", seed=seed)
73 assert culture.source_inventory in coastal_inventories
74 assert culture.feel == "coastal"
75
76 def test_forge_collision_handling(self, world_dir: Path):
77 forge = CultureForge(world_path=world_dir)
78 c1 = forge.forge(seed=99)
79 # Forge again with the same seed — must produce a unique name
80 c2 = forge.forge(seed=99)
81 assert c1.name != c2.name
82
83 def test_load_round_trips(self, world_dir: Path):
84 forge = CultureForge(world_path=world_dir)
85 original = forge.forge(seed=42)
86 loaded = forge.load(original.name)
87 assert loaded is not None
88 assert loaded.name == original.name
89 assert loaded.source_inventory == original.source_inventory
90 assert loaded.templates == original.templates
91 assert loaded.forbidden_clusters == original.forbidden_clusters
92 assert loaded.morphology.applies == original.morphology.applies
93
94 def test_load_missing_returns_none(self, world_dir: Path):
95 forge = CultureForge(world_path=world_dir)
96 assert forge.load("nonexistent") is None
97
98 def test_list_names_empty(self, world_dir: Path):
99 forge = CultureForge(world_path=world_dir)
100 assert forge.list_names() == []
101
102 def test_list_names_after_forge(self, world_dir: Path):
103 forge = CultureForge(world_path=world_dir)
104 names = [forge.forge(seed=s).name for s in range(3)]
105 assert sorted(names) == forge.list_names()
106
107
108class TestGenerator:
109 @pytest.fixture
110 def populated_world(self, tmp_path: Path) -> Path:
111 world = tmp_path / "world"
112 forge = CultureForge(world_path=world)
113 forge.forge(seed=42, feel="coastal")
114 forge.forge(seed=99, feel="highborn")
115 return world
116
117 def test_sample_returns_names(self, populated_world: Path):
118 gen = Generator(world_path=populated_world)
119 cultures = gen.list_cultures()
120 assert cultures
121 names = gen.sample(culture=cultures[0].name, count=5)
122 assert len(names) == 5
123 assert all(isinstance(n, str) for n in names)
124
125 def test_sample_distinct(self, populated_world: Path):
126 gen = Generator(world_path=populated_world)
127 cultures = gen.list_cultures()
128 names = gen.sample(culture=cultures[0].name, count=10)
129 assert len(set(names)) == len(names)
130
131 def test_sample_capitalized(self, populated_world: Path):
132 gen = Generator(world_path=populated_world)
133 cultures = gen.list_cultures()
134 for name in gen.sample(culture=cultures[0].name, count=5):
135 assert name[0].isupper()
136
137 def test_sample_unknown_culture_returns_empty(self, tmp_path: Path):
138 gen = Generator(world_path=tmp_path)
139 assert gen.sample(culture="ghost", count=5) == []
140
141 def test_place_kind(self, populated_world: Path):
142 gen = Generator(world_path=populated_world)
143 cultures = gen.list_cultures()
144 places = gen.sample(
145 culture=cultures[0].name,
146 count=3,
147 kind="place",
148 )
149 assert len(places) == 3
150
151 def test_list_cultures_returns_loaded(self, populated_world: Path):
152 gen = Generator(world_path=populated_world)
153 cultures = gen.list_cultures()
154 assert len(cultures) == 2
155 for c in cultures:
156 assert isinstance(c, ForgedCulture)
157
158 def test_rarity_uncommon(self, populated_world: Path):
159 gen = Generator(world_path=populated_world)
160 cultures = gen.list_cultures()
161 names = gen.sample(
162 culture=cultures[0].name,
163 count=3,
164 rarity="uncommon",
165 )
166 assert len(names) == 3
167
168 def test_rarity_archaic(self, populated_world: Path):
169 gen = Generator(world_path=populated_world)
170 cultures = gen.list_cultures()
171 names = gen.sample(
172 culture=cultures[0].name,
173 count=3,
174 rarity="archaic",
175 )
176 assert len(names) == 3