Coverage for little_loops/cli/loop/testing.py: 0%

1"""ll-loop testing subcommands: test, simulate."""

2

3 from __future__ import annotations

4

5 import argparse

6 from pathlib import Path

7

8 from little_loops.cli.loop._helpers import load_loop

9 from little_loops.logger import Logger

10

11

12 def cmd_test(

13 loop_name: str,

14 args: argparse.Namespace,

15 loops_dir: Path,

16 logger: Logger,

17) -> int:

18 """Run a single test iteration to verify loop configuration.

19

20 Executes the target state's action and evaluation, then reports

21 what the loop would do without actually transitioning further.

22 """

23 from little_loops.fsm.evaluators import EvaluationResult, evaluate, evaluate_exit_code

24 from little_loops.fsm.executor import DefaultActionRunner

25 from little_loops.fsm.interpolation import InterpolationContext

26

27 try:

28 fsm = load_loop(loop_name, loops_dir, logger)

29 except FileNotFoundError as e:

30 logger.error(str(e))

31 return 1

32 except ValueError as e:

33 logger.error(f"Validation error: {e}")

34 return 1

35

36 # Determine target state

37 target = args.state if args.state else fsm.initial

38 if target not in fsm.states:

39 logger.error(f"State '{target}' not found. Available: {', '.join(fsm.states)}")

40 return 1

41 state_config = fsm.states[target]

42

43 print(f"## Test Iteration: {loop_name}")

44 print()

45 print(f"State: {target}")

46

47 # If no action, report and exit

48 if not state_config.action:

49 print(f"State '{target}' has no action to test")

50 print()

51 print("\u2713 Loop structure is valid (no check action to execute)")

52 return 0

53

54 action = state_config.action

55 is_slash = action.startswith("/") or state_config.action_type in (

56 "prompt",

57 "slash_command",

58 )

59

60 print(f"Action: {action}")

61 print()

62

63 if is_slash:

64 from little_loops.fsm.executor import ActionResult, SimulationActionRunner

65

66 exit_code_arg = getattr(args, "exit_code", None)

67 if exit_code_arg is not None:

68 sim_exit_code = exit_code_arg

69 print(f"[SIMULATED] Using --exit-code {sim_exit_code}")

70 else:

71 sim_runner = SimulationActionRunner()

72 sim_result = sim_runner.run(action, timeout=120, is_slash_command=True)

73 sim_exit_code = sim_result.exit_code

74 print()

75 result = ActionResult(

76 output=f"[simulated output for: {action}]",

77 stderr="",

78 exit_code=sim_exit_code,

79 duration_ms=0,

80 )

81 else:

82 # Run the action

83 runner = DefaultActionRunner()

84 timeout = state_config.timeout or 120

85 result = runner.run(action, timeout=timeout, is_slash_command=False)

86

87 print(f"Exit code: {result.exit_code}")

88

89 # Truncate output for display

90 output_lines = result.output.strip().split("\n")

91 if len(output_lines) > 10:

92 extra = len(output_lines) - 10

93 output_preview = "\n".join(output_lines[:10]) + f"\n... ({extra} more lines)"

94 elif len(result.output) > 500:

95 output_preview = result.output[:500] + "..."

96 else:

97 output_preview = result.output.strip() if result.output.strip() else "(empty)"

98

99 print(f"Output:\n{output_preview}")

100

101 if result.stderr:

102 stderr_lines = result.stderr.strip().split("\n")

103 if len(stderr_lines) > 5:

104 extra = len(stderr_lines) - 5

105 stderr_preview = "\n".join(stderr_lines[:5]) + f"\n... ({extra} more lines)"

106 else:

107 stderr_preview = result.stderr.strip()

108 print(f"Stderr:\n{stderr_preview}")

109

110 print()

111

112 # Evaluate

113 ctx = InterpolationContext()

114 eval_result: EvaluationResult

115

116 if state_config.evaluate:

117 eval_result = evaluate(

118 config=state_config.evaluate,

119 output=result.output,

120 exit_code=result.exit_code,

121 context=ctx,

122 )

123 evaluator_type: str = state_config.evaluate.type

124 else:

125 # Default to exit_code evaluation

126 eval_result = evaluate_exit_code(result.exit_code)

127 evaluator_type = "exit_code (default)"

128

129 print(f"Evaluator: {evaluator_type}")

130 print(f"Verdict: {eval_result.verdict.upper()}")

131

132 if eval_result.details:

133 for key, value in eval_result.details.items():

134 if key != "exit_code" or evaluator_type != "exit_code (default)":

135 print(f" {key}: {value}")

136

137 # Determine next state based on verdict

138 verdict = eval_result.verdict

139 next_state = None

140

141 if state_config.route:

142 routes = state_config.route.routes

143 if verdict in routes:

144 next_state = routes[verdict]

145 elif state_config.route.default:

146 next_state = state_config.route.default

147 else:

148 if verdict == "yes" and state_config.on_yes:

149 next_state = state_config.on_yes

150 elif verdict == "no" and state_config.on_no:

151 next_state = state_config.on_no

152 elif verdict == "error" and state_config.on_error:

153 next_state = state_config.on_error

154

155 print()

156 if next_state:

157 print(f"Would transition: {target} \u2192 {next_state}")

158 else:

159 print(f"Would transition: {target} \u2192 (no route for '{verdict}')")

160

161 # Summary

162 print()

163 has_error = eval_result.verdict == "error" or "error" in eval_result.details

164 if has_error:

165 print("\u26a0 Loop has issues - review the error details above")

166 return 1

167 else:

168 print("\u2713 Loop appears to be configured correctly")

169 return 0

170

171

172 def cmd_simulate(

173 loop_name: str,

174 args: argparse.Namespace,

175 loops_dir: Path,

176 logger: Logger,

177) -> int:

178 """Run interactive simulation of loop execution.

179

180 Traces through loop logic without executing commands, allowing users

181 to verify state transitions and understand loop behavior.

182 """

183 from little_loops.fsm.executor import FSMExecutor, SimulationActionRunner

184

185 try:

186 fsm = load_loop(loop_name, loops_dir, logger)

187 except FileNotFoundError as e:

188 logger.error(str(e))

189 return 1

190 except ValueError as e:

191 logger.error(f"Validation error: {e}")

192 return 1

193

194 # Apply CLI overrides

195 if args.max_iterations:

196 fsm.max_iterations = args.max_iterations

197 else:

198 # Limit iterations for simulation safety (cap at 20 unless overridden)

199 if fsm.max_iterations > 20:

200 logger.info(f"Limiting simulation to 20 iterations (loop config: {fsm.max_iterations})")

201 fsm.max_iterations = 20

202

203 # Create simulation runner

204 sim_runner = SimulationActionRunner(scenario=args.scenario)

205

206 # Track simulation state

207 states_visited: list[str] = []

208

209 def simulation_callback(event: dict) -> None:

210 """Display simulation progress."""

211 event_type = event.get("event")

212

213 if event_type == "state_enter":

214 iteration = event.get("iteration", 0)

215 state = event.get("state", "")

216 states_visited.append(state)

217 print()

218 print(f"[{iteration}] State: {state}")

219

220 elif event_type == "action_start":

221 action = event.get("action", "")

222 action_display = action[:70] + "..." if len(action) > 70 else action

223 print(f" Action: {action_display}")

224

225 elif event_type == "evaluate":

226 evaluator = event.get("type", "exit_code")

227 verdict = event.get("verdict", "")

228 print(f" Evaluator: {evaluator}")

229 print(f" Result: {verdict.upper()}")

230

231 elif event_type == "route":

232 from_state = event.get("from", "")

233 to_state = event.get("to", "")

234 print(f" Transition: {from_state} \u2192 {to_state}")

235

236 # Print header

237 mode_str = f"scenario={args.scenario}" if args.scenario else "interactive"

238 print(f"=== SIMULATION: {fsm.name} ({mode_str}) ===")

239

240 # Run simulation

241 executor = FSMExecutor(

242 fsm,

243 event_callback=simulation_callback,

244 action_runner=sim_runner,

245 )

246 result = executor.run()

247

248 # Print summary

249 print()

250 print("=== Summary ===")

251 arrow = " \u2192 "

252 print(f"States visited: {arrow.join(states_visited)}")

253 print(f"Iterations: {result.iterations}")

254 print(f"Would have executed {len(sim_runner.calls)} commands")

255 print(f"Terminated by: {result.terminated_by}")

256

257 return 0