Skip to content

Commit 33a438c

Browse files
authored
Merge pull request #147 from CyberSecDef/copilot/add-tests-for-new-pipeline-steps
Add test coverage for 4 new chapter pipeline steps
2 parents 18a7b21 + 378e5e2 commit 33a438c

2 files changed

Lines changed: 353 additions & 0 deletions

File tree

tests/conftest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,22 @@ def _canned_llm_response(messages, *, action="", json_mode=False):
230230
if "revision" in act:
231231
return "The revised chapter content with improvements."
232232

233+
# Voice & dialogue differentiation pass
234+
if "voice" in act and "dialogue" in act:
235+
return "The character's voice and dialogue have been differentiated."
236+
237+
# Human oddities pass
238+
if "human oddities" in act:
239+
return "The chapter now includes small human, non-plot-serving moments."
240+
241+
# Metaphor reduction pass
242+
if "metaphor" in act:
243+
return "Excessive metaphors have been reduced in this chapter."
244+
245+
# Copy edit pass
246+
if "copy edit" in act:
247+
return "The chapter has been lightly copy-edited for prose clarity."
248+
233249
# Generic fallback — return the input text slightly modified
234250
return "Processed output from the LLM agent."
235251

tests/test_new_pipeline_prompts.py

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
"""
2+
Tests for the four new chapter-generation pipeline prompt builders and their
3+
pipeline integration under the shared ``mock_llm`` fixture.
4+
5+
Covers:
6+
- build_voice_dialogue_differentiation_prompt
7+
- build_human_oddities_prompt
8+
- build_metaphor_reduction_prompt
9+
- build_copy_edit_prompt
10+
- Pipeline integration: all four steps execute and produce non-empty output
11+
when wired through ``_run_all_chapter_agents`` with ``mock_llm``.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import pytest
17+
18+
from novelforge.agents.chapter.prompts import (
19+
build_copy_edit_prompt,
20+
build_human_oddities_prompt,
21+
build_metaphor_reduction_prompt,
22+
build_voice_dialogue_differentiation_prompt,
23+
)
24+
from novelforge.agents.chapter import _run_all_chapter_agents
25+
26+
# Import the canned response helper so integration tests can delegate to it
27+
from tests.conftest import _canned_llm_response
28+
29+
30+
# ---------------------------------------------------------------------------
31+
# Shared test data
32+
# ---------------------------------------------------------------------------
33+
34+
_CHAPTER_TEXT = (
35+
"The detective walked through the rain-soaked streets, "
36+
"her coat pulled tight against the wind."
37+
)
38+
_CHAPTER_NUM = 2
39+
_TITLE = "Shadows of the City"
40+
_CHARACTERS = "Alice — protagonist, seasoned detective\nBob — informant, nervous disposition"
41+
_TOTAL_CHAPTERS = 5
42+
43+
44+
# ---------------------------------------------------------------------------
45+
# build_voice_dialogue_differentiation_prompt
46+
# ---------------------------------------------------------------------------
47+
48+
49+
class TestBuildVoiceDialogueDifferentiationPrompt:
50+
"""Unit tests for the voice & dialogue differentiation prompt builder."""
51+
52+
def _call(
53+
self,
54+
chapter_text: str = _CHAPTER_TEXT,
55+
chapter_num: int = _CHAPTER_NUM,
56+
title: str = _TITLE,
57+
characters_text: str = _CHARACTERS,
58+
perspective_prompt: str = "",
59+
) -> list[dict[str, str]]:
60+
return build_voice_dialogue_differentiation_prompt(
61+
chapter_text=chapter_text,
62+
chapter_num=chapter_num,
63+
title=title,
64+
characters_text=characters_text,
65+
perspective_prompt=perspective_prompt,
66+
)
67+
68+
def test_returns_list_of_message_dicts(self):
69+
result = self._call()
70+
assert isinstance(result, list)
71+
assert len(result) >= 1
72+
for msg in result:
73+
assert isinstance(msg, dict)
74+
assert "role" in msg
75+
assert "content" in msg
76+
77+
def test_chapter_text_appears_in_prompt(self):
78+
result = self._call()
79+
combined = " ".join(m["content"] for m in result)
80+
assert _CHAPTER_TEXT in combined
81+
82+
def test_characters_text_appears_in_prompt(self):
83+
result = self._call()
84+
combined = " ".join(m["content"] for m in result)
85+
assert _CHARACTERS in combined
86+
87+
def test_perspective_prompt_appears_when_provided(self):
88+
perspective = "Third-person limited, close to Alice."
89+
result = self._call(perspective_prompt=perspective)
90+
combined = " ".join(m["content"] for m in result)
91+
assert perspective in combined
92+
93+
def test_empty_perspective_prompt_does_not_raise(self):
94+
result = self._call(perspective_prompt="")
95+
assert isinstance(result, list)
96+
97+
def test_deterministic_for_same_inputs(self):
98+
r1 = self._call()
99+
r2 = self._call()
100+
assert r1 == r2
101+
102+
103+
# ---------------------------------------------------------------------------
104+
# build_human_oddities_prompt
105+
# ---------------------------------------------------------------------------
106+
107+
108+
class TestBuildHumanOdditiesPrompt:
109+
"""Unit tests for the human oddities prompt builder."""
110+
111+
def _call(
112+
self,
113+
chapter_text: str = _CHAPTER_TEXT,
114+
chapter_num: int = _CHAPTER_NUM,
115+
title: str = _TITLE,
116+
total_chapters: int = _TOTAL_CHAPTERS,
117+
characters_text: str = _CHARACTERS,
118+
) -> list[dict[str, str]]:
119+
return build_human_oddities_prompt(
120+
chapter_text=chapter_text,
121+
chapter_num=chapter_num,
122+
title=title,
123+
total_chapters=total_chapters,
124+
characters_text=characters_text,
125+
)
126+
127+
def test_returns_list_of_message_dicts(self):
128+
result = self._call()
129+
assert isinstance(result, list)
130+
assert len(result) >= 1
131+
for msg in result:
132+
assert isinstance(msg, dict)
133+
assert "role" in msg
134+
assert "content" in msg
135+
136+
def test_chapter_text_appears_in_prompt(self):
137+
result = self._call()
138+
combined = " ".join(m["content"] for m in result)
139+
assert _CHAPTER_TEXT in combined
140+
141+
def test_characters_text_appears_in_prompt(self):
142+
result = self._call()
143+
combined = " ".join(m["content"] for m in result)
144+
assert _CHARACTERS in combined
145+
146+
def test_deterministic_for_same_inputs(self):
147+
r1 = self._call()
148+
r2 = self._call()
149+
assert r1 == r2
150+
151+
def test_different_total_chapters_changes_output(self):
152+
r1 = self._call(total_chapters=3)
153+
r2 = self._call(total_chapters=20)
154+
# At least one message must differ (chapter position context changes)
155+
assert r1 != r2
156+
157+
158+
# ---------------------------------------------------------------------------
159+
# build_metaphor_reduction_prompt
160+
# ---------------------------------------------------------------------------
161+
162+
163+
class TestBuildMetaphorReductionPrompt:
164+
"""Unit tests for the metaphor reduction prompt builder."""
165+
166+
def _call(
167+
self,
168+
chapter_text: str = _CHAPTER_TEXT,
169+
chapter_num: int = _CHAPTER_NUM,
170+
title: str = _TITLE,
171+
) -> list[dict[str, str]]:
172+
return build_metaphor_reduction_prompt(
173+
chapter_text=chapter_text,
174+
chapter_num=chapter_num,
175+
title=title,
176+
)
177+
178+
def test_returns_list_of_message_dicts(self):
179+
result = self._call()
180+
assert isinstance(result, list)
181+
assert len(result) >= 1
182+
for msg in result:
183+
assert isinstance(msg, dict)
184+
assert "role" in msg
185+
assert "content" in msg
186+
187+
def test_chapter_text_appears_in_prompt(self):
188+
result = self._call()
189+
combined = " ".join(m["content"] for m in result)
190+
assert _CHAPTER_TEXT in combined
191+
192+
def test_deterministic_for_same_inputs(self):
193+
r1 = self._call()
194+
r2 = self._call()
195+
assert r1 == r2
196+
197+
def test_different_titles_produce_different_prompts(self):
198+
r1 = self._call(title="Title One")
199+
r2 = self._call(title="Title Two")
200+
assert r1 != r2
201+
202+
203+
# ---------------------------------------------------------------------------
204+
# build_copy_edit_prompt
205+
# ---------------------------------------------------------------------------
206+
207+
208+
class TestBuildCopyEditPrompt:
209+
"""Unit tests for the copy-edit prompt builder."""
210+
211+
def _call(
212+
self,
213+
chapter_text: str = _CHAPTER_TEXT,
214+
chapter_num: int = _CHAPTER_NUM,
215+
title: str = _TITLE,
216+
) -> list[dict[str, str]]:
217+
return build_copy_edit_prompt(
218+
chapter_text=chapter_text,
219+
chapter_num=chapter_num,
220+
title=title,
221+
)
222+
223+
def test_returns_list_of_message_dicts(self):
224+
result = self._call()
225+
assert isinstance(result, list)
226+
assert len(result) >= 1
227+
for msg in result:
228+
assert isinstance(msg, dict)
229+
assert "role" in msg
230+
assert "content" in msg
231+
232+
def test_chapter_text_appears_in_prompt(self):
233+
result = self._call()
234+
combined = " ".join(m["content"] for m in result)
235+
assert _CHAPTER_TEXT in combined
236+
237+
def test_deterministic_for_same_inputs(self):
238+
r1 = self._call()
239+
r2 = self._call()
240+
assert r1 == r2
241+
242+
def test_different_chapter_numbers_produce_different_prompts(self):
243+
r1 = self._call(chapter_num=1)
244+
r2 = self._call(chapter_num=5)
245+
assert r1 != r2
246+
247+
248+
# ---------------------------------------------------------------------------
249+
# Pipeline integration: all four steps execute under mock_llm
250+
# ---------------------------------------------------------------------------
251+
252+
253+
class TestNewStepsPipelineIntegration:
254+
"""
255+
Verify that all four new pipeline steps are wired into _run_all_chapter_agents
256+
and execute without error under the shared mock_llm fixture.
257+
258+
Strategy: additionally patch ``novelforge.agents.chapter._helpers.call_llm``
259+
with a recording wrapper so we can inspect which ``action`` strings were
260+
passed, then assert each new step is present.
261+
"""
262+
263+
_PIPELINE_KWARGS = dict(
264+
text=_CHAPTER_TEXT,
265+
chapter_num=_CHAPTER_NUM,
266+
title=_TITLE,
267+
genre="Mystery",
268+
total_chapters=_TOTAL_CHAPTERS,
269+
chapter_outline_summary="A detective investigates a cold case.",
270+
characters_text=_CHARACTERS,
271+
previous_summaries="Chapter 1: Setup.",
272+
)
273+
274+
def _run_and_collect_actions(self, mock_llm, mocker) -> list[str]:
275+
"""Run the full pipeline and return the list of recorded action strings."""
276+
actions: list[str] = []
277+
278+
def _recording(messages, *, action: str = "", json_mode: bool = False) -> str:
279+
actions.append(action)
280+
return _canned_llm_response(messages, action=action, json_mode=json_mode)
281+
282+
# Layer a recording wrapper on top of the mock_llm patch for the module
283+
# that actually executes the calls inside _run_all_chapter_agents.
284+
mocker.patch(
285+
"novelforge.agents.chapter._helpers.call_llm",
286+
side_effect=_recording,
287+
)
288+
_run_all_chapter_agents(**self._PIPELINE_KWARGS)
289+
return actions
290+
291+
@staticmethod
292+
def _action_present(actions: list[str], *substrings: str) -> bool:
293+
"""Return True if any recorded action contains all of the given substrings."""
294+
return any(all(s in a for s in substrings) for a in actions)
295+
296+
def test_voice_dialogue_step_executes(self, mock_llm, mocker):
297+
actions = self._run_and_collect_actions(mock_llm, mocker)
298+
assert self._action_present(actions, "voice", "dialogue"), (
299+
f"Expected 'voice & dialogue differentiation' action in LLM calls; "
300+
f"got: {actions}"
301+
)
302+
303+
def test_human_oddities_step_executes(self, mock_llm, mocker):
304+
actions = self._run_and_collect_actions(mock_llm, mocker)
305+
assert self._action_present(actions, "human oddities"), (
306+
f"Expected 'human oddities' action in LLM calls; got: {actions}"
307+
)
308+
309+
def test_metaphor_reduction_step_executes(self, mock_llm, mocker):
310+
actions = self._run_and_collect_actions(mock_llm, mocker)
311+
assert self._action_present(actions, "metaphor"), (
312+
f"Expected 'metaphor reduction' action in LLM calls; got: {actions}"
313+
)
314+
315+
def test_copy_edit_step_executes(self, mock_llm, mocker):
316+
actions = self._run_and_collect_actions(mock_llm, mocker)
317+
assert self._action_present(actions, "copy edit"), (
318+
f"Expected 'copy edit' action in LLM calls; got: {actions}"
319+
)
320+
321+
def test_all_four_steps_execute_in_single_run(self, mock_llm, mocker):
322+
"""All four new steps must appear in a single pipeline execution."""
323+
actions = self._run_and_collect_actions(mock_llm, mocker)
324+
assert self._action_present(actions, "voice", "dialogue"), "voice & dialogue step missing"
325+
assert self._action_present(actions, "human oddities"), "human oddities step missing"
326+
assert self._action_present(actions, "metaphor"), "metaphor reduction step missing"
327+
assert self._action_present(actions, "copy edit"), "copy edit step missing"
328+
329+
def test_pipeline_returns_non_empty_text_and_summary(self, mock_llm, mocker):
330+
"""Pipeline must return a non-empty (text, summary) tuple."""
331+
mocker.patch(
332+
"novelforge.agents.chapter._helpers.call_llm",
333+
side_effect=_canned_llm_response,
334+
)
335+
text, summary = _run_all_chapter_agents(**self._PIPELINE_KWARGS)
336+
assert isinstance(text, str) and text.strip()
337+
assert isinstance(summary, str) and summary.strip()

0 commit comments

Comments
 (0)