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

152 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-03-18 16:20 -0500

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

2 

3from __future__ import annotations 

4 

5import argparse 

6from pathlib import Path 

7 

8from little_loops.cli.loop._helpers import load_loop 

9from little_loops.logger import Logger 

10 

11 

12def 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 

172def 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