/ scripts / generate-tests.py
generate-tests.py
  1  #!/usr/bin/env python3
  2  """
  3  Test stub generator for Alpha-Delta frontend repos.
  4  
  5  Parses component cspec files and generates test stubs for:
  6  - TypeScript/React (vitest/jest)
  7  - Rust (cargo test)
  8  
  9  Usage:
 10      python scripts/generate-tests.py F002  # Generate for specific component
 11      python scripts/generate-tests.py --all  # Generate for all components
 12      python scripts/generate-tests.py F001 --output /path/to/repo  # Custom output
 13  """
 14  
 15  import argparse
 16  import os
 17  import re
 18  import sys
 19  from pathlib import Path
 20  from typing import Optional
 21  
 22  # Component cspec directory
 23  CSPEC_DIR = Path(__file__).parent.parent / "components" / "frontend"
 24  
 25  # Repo mapping
 26  REPO_MAP = {
 27      "F001": {"name": "wallet-core", "lang": "rust"},
 28      "F002": {"name": "acdc-wallet", "lang": "typescript"},
 29      "F003": {"name": "acdc-governor", "lang": "typescript"},
 30      "F004": {"name": "acdc-messenger", "lang": "typescript"},
 31      "F005": {"name": "acdc-cli", "lang": "rust"},
 32      "F006": {"name": "acdc-scanner", "lang": "typescript"},
 33      "F007": {"name": "acdc-docs", "lang": "typescript"},
 34      "F008": {"name": "acdc-design", "lang": "typescript"},
 35      "F009": {"name": "acdc-i18n", "lang": "typescript"},
 36      "F010": {"name": "acdc-contracts", "lang": "leo"},
 37  }
 38  
 39  
 40  def parse_cspec(cspec_path: Path) -> dict:
 41      """Parse a cspec file and extract interface definitions."""
 42      content = cspec_path.read_text()
 43      lines = content.split('\n')
 44  
 45      result = {
 46          "id": "",
 47          "name": "",
 48          "functions": {},
 49          "events": [],
 50          "types": {},
 51      }
 52  
 53      # Extract metadata
 54      id_match = re.search(r'id:\s*(\w+)', content)
 55      name_match = re.search(r'name:\s*(\w+)', content)
 56      if id_match:
 57          result["id"] = id_match.group(1)
 58      if name_match:
 59          result["name"] = name_match.group(1)
 60  
 61      # State machine for parsing
 62      in_interface = False
 63      in_functions = False
 64      current_func = None
 65      current_func_data = {"inputs": [], "outputs": []}
 66  
 67      for i, line in enumerate(lines):
 68          stripped = line.strip()
 69  
 70          # Track sections
 71          if line.startswith('interface:'):
 72              in_interface = True
 73              continue
 74          elif line.startswith('spec:') or line.startswith('dependencies:'):
 75              in_interface = False
 76              in_functions = False
 77              if current_func:
 78                  result["functions"][current_func] = current_func_data
 79                  current_func = None
 80              continue
 81  
 82          if not in_interface:
 83              continue
 84  
 85          # Inside interface section
 86          if stripped.startswith('functions:'):
 87              in_functions = True
 88              continue
 89          elif stripped.startswith('events:'):
 90              in_functions = False
 91              if current_func:
 92                  result["functions"][current_func] = current_func_data
 93                  current_func = None
 94              continue
 95          elif stripped.startswith('types:') or stripped.startswith('types_ref:'):
 96              in_functions = False
 97              if current_func:
 98                  result["functions"][current_func] = current_func_data
 99                  current_func = None
100              continue
101  
102          if in_functions and stripped:
103              # Single-line format: func_name: {inputs: [...], outputs: [...]}
104              single_line_match = re.match(r'(\w+):\s*\{inputs:\s*\[([^\]]*)\],\s*outputs:\s*\[([^\]]*)\]\}', stripped)
105              if single_line_match:
106                  func_name = single_line_match.group(1)
107                  inputs = [i.strip() for i in single_line_match.group(2).split(',') if i.strip()]
108                  outputs = [o.strip() for o in single_line_match.group(3).split(',') if o.strip()]
109                  result["functions"][func_name] = {"inputs": inputs, "outputs": outputs}
110                  continue
111  
112              # Multi-line format detection
113              # Function name line: "    func_name:"
114              func_name_match = re.match(r'^(\s{4})(\w+):$', line)
115              if func_name_match:
116                  # Save previous function
117                  if current_func:
118                      result["functions"][current_func] = current_func_data
119                  current_func = func_name_match.group(2)
120                  current_func_data = {"inputs": [], "outputs": []}
121                  continue
122  
123              # Inputs line: "      inputs: [...]"
124              if current_func and 'inputs:' in stripped:
125                  inputs_match = re.search(r'inputs:\s*\[([^\]]*)\]', stripped)
126                  if inputs_match:
127                      inputs_str = inputs_match.group(1)
128                      current_func_data["inputs"] = [i.split(':')[0].strip() for i in inputs_str.split(',') if i.strip()]
129                  continue
130  
131              # Outputs line: "      outputs: [...]"
132              if current_func and 'outputs:' in stripped:
133                  outputs_match = re.search(r'outputs:\s*\[([^\]]*)\]', stripped)
134                  if outputs_match:
135                      outputs_str = outputs_match.group(1)
136                      current_func_data["outputs"] = [o.split(':')[0].strip() for o in outputs_str.split(',') if o.strip()]
137                  continue
138  
139      # Save last function if any
140      if current_func:
141          result["functions"][current_func] = current_func_data
142  
143      # Extract events
144      events_match = re.search(r'events:\s*\n((?:\s+-\s+\w+\n?)+)', content)
145      if events_match:
146          events_block = events_match.group(1)
147          result["events"] = [e.strip()[2:].strip() for e in events_block.strip().split('\n') if e.strip().startswith('-')]
148  
149      return result
150  
151  
152  def generate_typescript_tests(spec: dict, output_dir: Path) -> list[Path]:
153      """Generate TypeScript/React test stubs."""
154      generated = []
155  
156      # Create test directory
157      test_dir = output_dir / "src" / "__tests__"
158      test_dir.mkdir(parents=True, exist_ok=True)
159  
160      # Generate function tests
161      if spec["functions"]:
162          func_test_path = test_dir / f"{spec['name']}.test.ts"
163  
164          test_content = f'''/**
165   * Auto-generated test stubs for {spec['name']} ({spec['id']})
166   * Generated from: components/frontend/{spec['id']}-{spec['name']}.component.cspec
167   *
168   * TODO: Implement these test cases
169   */
170  
171  import {{ describe, it, expect, vi, beforeEach }} from 'vitest';
172  
173  '''
174  
175          for func_name, func_def in spec["functions"].items():
176              inputs = func_def["inputs"]
177              outputs = func_def["outputs"]
178  
179              input_params = ", ".join([f"{i}: unknown" for i in inputs]) if inputs else ""
180  
181              test_content += f'''describe('{func_name}', () => {{
182    beforeEach(() => {{
183      // Setup mocks
184    }});
185  
186    it('should handle valid input', async () => {{
187      // TODO: Implement test
188      // Inputs: {inputs}
189      // Expected outputs: {outputs}
190      expect(true).toBe(true);
191    }});
192  
193    it('should reject invalid input', async () => {{
194      // TODO: Test error cases
195      expect(true).toBe(true);
196    }});
197  
198    it('should handle edge cases', async () => {{
199      // TODO: Test boundary conditions
200      expect(true).toBe(true);
201    }});
202  }});
203  
204  '''
205  
206          func_test_path.write_text(test_content)
207          generated.append(func_test_path)
208  
209      # Generate event tests
210      if spec["events"]:
211          event_test_path = test_dir / f"{spec['name']}.events.test.ts"
212  
213          event_content = f'''/**
214   * Auto-generated event test stubs for {spec['name']} ({spec['id']})
215   * Generated from: components/frontend/{spec['id']}-{spec['name']}.component.cspec
216   */
217  
218  import {{ describe, it, expect, vi }} from 'vitest';
219  
220  '''
221  
222          for event in spec["events"]:
223              event_content += f'''describe('Event: {event}', () => {{
224    it('should emit {event} when triggered', () => {{
225      // TODO: Implement event emission test
226      expect(true).toBe(true);
227    }});
228  
229    it('should include correct payload in {event}', () => {{
230      // TODO: Verify event payload structure
231      expect(true).toBe(true);
232    }});
233  }});
234  
235  '''
236  
237          event_test_path.write_text(event_content)
238          generated.append(event_test_path)
239  
240      return generated
241  
242  
243  def generate_rust_tests(spec: dict, output_dir: Path) -> list[Path]:
244      """Generate Rust test stubs."""
245      generated = []
246  
247      # Create test directory
248      test_dir = output_dir / "tests"
249      test_dir.mkdir(parents=True, exist_ok=True)
250  
251      if spec["functions"]:
252          func_test_path = test_dir / f"{spec['name']}_test.rs"
253  
254          test_content = f'''//! Auto-generated test stubs for {spec['name']} ({spec['id']})
255  //! Generated from: components/frontend/{spec['id']}-{spec['name']}.component.cspec
256  //!
257  //! TODO: Implement these test cases
258  
259  use {spec['name'].replace('-', '_')}::*;
260  
261  '''
262  
263          for func_name, func_def in spec["functions"].items():
264              inputs = func_def["inputs"]
265              outputs = func_def["outputs"]
266  
267              test_content += f'''#[cfg(test)]
268  mod {func_name}_tests {{
269      use super::*;
270  
271      #[test]
272      fn test_{func_name}_valid_input() {{
273          // TODO: Implement test
274          // Inputs: {inputs}
275          // Expected outputs: {outputs}
276          assert!(true);
277      }}
278  
279      #[test]
280      fn test_{func_name}_invalid_input() {{
281          // TODO: Test error cases
282          assert!(true);
283      }}
284  
285      #[test]
286      fn test_{func_name}_edge_cases() {{
287          // TODO: Test boundary conditions
288          assert!(true);
289      }}
290  }}
291  
292  '''
293  
294          func_test_path.write_text(test_content)
295          generated.append(func_test_path)
296  
297      return generated
298  
299  
300  def generate_tests(component_id: str, output_dir: Optional[Path] = None) -> list[Path]:
301      """Generate test stubs for a component."""
302  
303      if component_id not in REPO_MAP:
304          print(f"Error: Unknown component {component_id}")
305          return []
306  
307      repo_info = REPO_MAP[component_id]
308  
309      # Find cspec file
310      cspec_pattern = f"{component_id}-*.cspec"
311      cspec_files = list(CSPEC_DIR.glob(cspec_pattern))
312  
313      if not cspec_files:
314          print(f"Error: No cspec found for {component_id}")
315          return []
316  
317      cspec_path = cspec_files[0]
318  
319      # Parse cspec
320      spec = parse_cspec(cspec_path)
321  
322      if not spec["functions"] and not spec["events"]:
323          print(f"Warning: No functions or events found in {cspec_path}")
324          return []
325  
326      # Determine output directory
327      if output_dir is None:
328          # Default to sibling repo directory
329          output_dir = CSPEC_DIR.parent.parent.parent / repo_info["name"]
330  
331      if not output_dir.exists():
332          print(f"Warning: Output directory {output_dir} does not exist, creating...")
333          output_dir.mkdir(parents=True, exist_ok=True)
334  
335      # Generate tests based on language
336      lang = repo_info["lang"]
337  
338      if lang == "typescript":
339          return generate_typescript_tests(spec, output_dir)
340      elif lang == "rust":
341          return generate_rust_tests(spec, output_dir)
342      elif lang == "leo":
343          print(f"Leo test generation not yet implemented for {component_id}")
344          return []
345      else:
346          print(f"Unknown language {lang} for {component_id}")
347          return []
348  
349  
350  def main():
351      parser = argparse.ArgumentParser(description="Generate test stubs from cspec files")
352      parser.add_argument("component", nargs="?", help="Component ID (e.g., F002)")
353      parser.add_argument("--all", action="store_true", help="Generate for all components")
354      parser.add_argument("--output", "-o", type=Path, help="Output directory")
355      parser.add_argument("--list", action="store_true", help="List available components")
356  
357      args = parser.parse_args()
358  
359      if args.list:
360          print("Available components:")
361          for cid, info in REPO_MAP.items():
362              print(f"  {cid}: {info['name']} ({info['lang']})")
363          return
364  
365      if args.all:
366          components = list(REPO_MAP.keys())
367      elif args.component:
368          components = [args.component.upper()]
369      else:
370          parser.print_help()
371          return
372  
373      total_generated = []
374  
375      for component_id in components:
376          print(f"\nGenerating tests for {component_id}...")
377          generated = generate_tests(component_id, args.output)
378          total_generated.extend(generated)
379  
380          for path in generated:
381              print(f"  Created: {path}")
382  
383      print(f"\n✓ Generated {len(total_generated)} test files")
384  
385  
386  if __name__ == "__main__":
387      main()