# -*- coding: utf-8 -*- """ Hypothesis strategies for property-based testing of the Survey Custom Certificate Template module. This module provides custom Hypothesis strategies for generating test data including: - DOCX files with placeholders - Placeholder mappings - Participant data - Valid complete mapping structures These strategies are used by property-based tests to verify correctness properties across a wide range of randomly generated inputs. """ import io import logging from datetime import datetime, timedelta from hypothesis import strategies as st from hypothesis.strategies import composite try: from docx import Document DOCX_AVAILABLE = True except ImportError: DOCX_AVAILABLE = False Document = None _logger = logging.getLogger(__name__) # ============================================================================ # Basic Building Blocks # ============================================================================ @composite def placeholder_keys(draw): """ Generate valid placeholder keys in the format {key.field_name}. Field names can contain ASCII letters, numbers, and underscores. Returns: str: A valid placeholder key like "{key.name}" or "{key.field_123}" """ # Generate field name with ASCII letters, numbers, and underscores # Use ASCII range to avoid unicode characters field_name = draw(st.text( alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_', min_size=1, max_size=50 )) # Ensure it starts with a letter (not a number or underscore) if not field_name[0].isalpha(): field_name = 'field_' + field_name return f'{{key.{field_name}}}' @composite def text_with_placeholders(draw, min_placeholders=0, max_placeholders=5): """ Generate text content containing placeholders. Args: min_placeholders: Minimum number of placeholders to include max_placeholders: Maximum number of placeholders to include Returns: tuple: (text_content, list_of_placeholders) """ num_placeholders = draw(st.integers(min_value=min_placeholders, max_value=max_placeholders)) # Generate unique placeholders placeholders = [] for _ in range(num_placeholders): placeholder = draw(placeholder_keys()) if placeholder not in placeholders: placeholders.append(placeholder) # Generate text segments (XML-safe characters only) # Exclude control characters and use printable ASCII + common unicode text_segments = [] for _ in range(len(placeholders) + 1): segment = draw(st.text( alphabet=st.characters( min_codepoint=32, # Space max_codepoint=126, # Tilde (printable ASCII) blacklist_characters='{}<>' # Exclude these for safety ), min_size=0, max_size=50 )) text_segments.append(segment) # Interleave text segments and placeholders text_content = text_segments[0] for i, placeholder in enumerate(placeholders): text_content += placeholder if i + 1 < len(text_segments): text_content += text_segments[i + 1] return text_content, placeholders # ============================================================================ # DOCX Generation Strategies # ============================================================================ @composite def docx_with_placeholders(draw, min_placeholders=1, max_placeholders=10): """ Generate a DOCX file with known placeholders. This strategy creates a valid DOCX document containing paragraphs with placeholders in the format {key.field_name}. The document structure includes paragraphs, tables, headers, and footers. Args: min_placeholders: Minimum number of placeholders to include max_placeholders: Maximum number of placeholders to include Returns: tuple: (docx_binary, list_of_unique_placeholders) - docx_binary: Binary content of the DOCX file - list_of_unique_placeholders: Sorted list of unique placeholder strings Raises: ImportError: If python-docx is not available """ if not DOCX_AVAILABLE: raise ImportError("python-docx is required for this strategy") doc = Document() all_placeholders = set() # Determine number of placeholders num_placeholders = draw(st.integers(min_value=min_placeholders, max_value=max_placeholders)) # Add paragraphs with placeholders num_paragraphs = draw(st.integers(min_value=1, max_value=5)) for _ in range(num_paragraphs): text, placeholders = draw(text_with_placeholders( min_placeholders=0, max_placeholders=max(1, num_placeholders // num_paragraphs + 1) )) if text.strip(): # Only add non-empty paragraphs doc.add_paragraph(text) all_placeholders.update(placeholders) # Optionally add a table with placeholders add_table = draw(st.booleans()) if add_table and len(all_placeholders) < num_placeholders: rows = draw(st.integers(min_value=1, max_value=3)) cols = draw(st.integers(min_value=1, max_value=3)) table = doc.add_table(rows=rows, cols=cols) for row in table.rows: for cell in row.cells: text, placeholders = draw(text_with_placeholders( min_placeholders=0, max_placeholders=2 )) if text.strip(): cell.text = text all_placeholders.update(placeholders) # Optionally add header/footer with placeholders add_header_footer = draw(st.booleans()) if add_header_footer and len(all_placeholders) < num_placeholders: section = doc.sections[0] # Add header text, placeholders = draw(text_with_placeholders( min_placeholders=0, max_placeholders=1 )) if text.strip(): section.header.paragraphs[0].text = text all_placeholders.update(placeholders) # Add footer text, placeholders = draw(text_with_placeholders( min_placeholders=0, max_placeholders=1 )) if text.strip(): section.footer.paragraphs[0].text = text all_placeholders.update(placeholders) # Ensure we have at least min_placeholders while len(all_placeholders) < min_placeholders: placeholder = draw(placeholder_keys()) doc.add_paragraph(f"Additional content: {placeholder}") all_placeholders.add(placeholder) # Save to bytes doc_stream = io.BytesIO() doc.save(doc_stream) doc_stream.seek(0) docx_binary = doc_stream.read() # Return sorted list for consistency return docx_binary, sorted(list(all_placeholders)) # ============================================================================ # Mapping Strategies # ============================================================================ @composite def placeholder_mappings(draw): """ Generate a single placeholder mapping dictionary. A placeholder mapping defines how a placeholder should be replaced with data. It includes the placeholder key, value type, and associated field or custom text. Returns: dict: A placeholder mapping with keys: - key: The placeholder string (e.g., "{key.name}") - value_type: One of 'survey_field', 'user_field', or 'custom_text' - value_field: Field name if value_type is not 'custom_text' - custom_text: Custom text if value_type is 'custom_text' """ placeholder_key = draw(placeholder_keys()) value_type = draw(st.sampled_from(['survey_field', 'user_field', 'custom_text'])) mapping = { 'key': placeholder_key, 'value_type': value_type, } if value_type == 'custom_text': # Generate custom text (can be empty or contain any text) # Use printable characters only to avoid XML issues custom_text = draw(st.text( alphabet=st.characters( min_codepoint=32, # Space max_codepoint=126, # Tilde (printable ASCII) blacklist_characters='<>&' # Exclude XML special chars ), max_size=200 )) mapping['value_field'] = '' mapping['custom_text'] = custom_text else: # Generate a field name if value_type == 'survey_field': # Common survey fields value_field = draw(st.sampled_from([ 'survey_title', 'survey_description', ])) else: # user_field # Common user/participant fields value_field = draw(st.sampled_from([ 'partner_name', 'partner_email', 'email', 'create_date', 'completion_date', 'scoring_percentage', 'scoring_total', ])) mapping['value_field'] = value_field mapping['custom_text'] = '' return mapping @composite def valid_mappings(draw, min_placeholders=1, max_placeholders=10): """ Generate a complete valid mappings structure. This strategy creates a full mappings dictionary as stored in the database, with a list of placeholder mappings. Args: min_placeholders: Minimum number of placeholder mappings max_placeholders: Maximum number of placeholder mappings Returns: dict: A complete mappings structure with: - placeholders: List of placeholder mapping dictionaries """ num_placeholders = draw(st.integers(min_value=min_placeholders, max_value=max_placeholders)) # Generate unique placeholder mappings placeholders = [] used_keys = set() for _ in range(num_placeholders): mapping = draw(placeholder_mappings()) # Ensure unique keys if mapping['key'] not in used_keys: placeholders.append(mapping) used_keys.add(mapping['key']) return { 'placeholders': placeholders } # ============================================================================ # Participant Data Strategies # ============================================================================ @composite def participant_data(draw): """ Generate random participant data for certificate generation. This strategy creates a dictionary containing all possible fields that might be used in certificate generation, including survey data and participant information. Returns: dict: Participant data dictionary with keys: - survey_title: Survey/course title - survey_description: Survey/course description - partner_name: Participant's full name - partner_email: Participant's email address - email: Alternative email field - create_date: Creation/submission date - completion_date: Completion date - scoring_percentage: Score as percentage - scoring_total: Total score points """ # Generate realistic names first_names = ['John', 'Jane', 'Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank'] last_names = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis'] first_name = draw(st.sampled_from(first_names)) last_name = draw(st.sampled_from(last_names)) full_name = f"{first_name} {last_name}" # Generate email from name email = f"{first_name.lower()}.{last_name.lower()}@example.com" # Generate dates base_date = datetime.now() days_ago = draw(st.integers(min_value=0, max_value=365)) completion_date = base_date - timedelta(days=days_ago) # Generate scores scoring_percentage = draw(st.floats(min_value=0.0, max_value=100.0)) scoring_total = draw(st.integers(min_value=0, max_value=100)) # Generate survey info survey_titles = [ 'Introduction to Python Programming', 'Advanced Web Development', 'Data Science Fundamentals', 'Machine Learning Basics', 'Cybersecurity Essentials', 'Cloud Computing Overview', ] survey_title = draw(st.sampled_from(survey_titles)) survey_description = draw(st.text( alphabet=st.characters(whitelist_categories=('Lu', 'Ll', 'Nd', 'Zs')), min_size=10, max_size=200 )) return { 'survey_title': survey_title, 'survey_description': survey_description, 'partner_name': full_name, 'partner_email': email, 'email': email, 'create_date': completion_date.strftime('%Y-%m-%d'), 'completion_date': completion_date.strftime('%Y-%m-%d'), 'scoring_percentage': f'{scoring_percentage:.1f}', 'scoring_total': str(scoring_total), } # ============================================================================ # Combined Strategies # ============================================================================ @composite def docx_with_mappings_and_data(draw): """ Generate a complete test case: DOCX with placeholders, mappings, and data. This strategy creates a coordinated set of test inputs where: - The DOCX contains specific placeholders - The mappings define how to replace those placeholders - The data contains values for all mapped fields Returns: tuple: (docx_binary, mappings, data) - docx_binary: Binary content of DOCX file - mappings: Complete mappings dictionary - data: Participant data dictionary """ # Generate DOCX with placeholders docx_binary, placeholders = draw(docx_with_placeholders(min_placeholders=1, max_placeholders=8)) # Generate mappings for these specific placeholders mappings_list = [] for placeholder in placeholders: value_type = draw(st.sampled_from(['survey_field', 'user_field', 'custom_text'])) mapping = { 'key': placeholder, 'value_type': value_type, } if value_type == 'custom_text': # Use printable characters only to avoid XML issues custom_text = draw(st.text( alphabet=st.characters( min_codepoint=32, # Space max_codepoint=126, # Tilde (printable ASCII) blacklist_characters='<>&' # Exclude XML special chars ), max_size=100 )) mapping['value_field'] = '' mapping['custom_text'] = custom_text else: if value_type == 'survey_field': value_field = draw(st.sampled_from(['survey_title', 'survey_description'])) else: value_field = draw(st.sampled_from([ 'partner_name', 'partner_email', 'completion_date', 'scoring_percentage', 'scoring_total' ])) mapping['value_field'] = value_field mapping['custom_text'] = '' mappings_list.append(mapping) mappings = {'placeholders': mappings_list} # Generate participant data data = draw(participant_data()) return docx_binary, mappings, data # ============================================================================ # Edge Case Strategies # ============================================================================ @composite def empty_docx(draw): """ Generate an empty DOCX file with no placeholders. Returns: bytes: Binary content of an empty DOCX file """ if not DOCX_AVAILABLE: raise ImportError("python-docx is required for this strategy") doc = Document() doc.add_paragraph("This is a static certificate with no placeholders.") doc_stream = io.BytesIO() doc.save(doc_stream) doc_stream.seek(0) return doc_stream.read() @composite def docx_with_duplicate_placeholders(draw): """ Generate a DOCX file where the same placeholder appears multiple times. Returns: tuple: (docx_binary, list_of_unique_placeholders) """ if not DOCX_AVAILABLE: raise ImportError("python-docx is required for this strategy") doc = Document() # Generate a few unique placeholders num_unique = draw(st.integers(min_value=1, max_value=5)) unique_placeholders = [draw(placeholder_keys()) for _ in range(num_unique)] # Add paragraphs with repeated placeholders num_paragraphs = draw(st.integers(min_value=2, max_value=6)) for _ in range(num_paragraphs): # Pick a random placeholder to repeat placeholder = draw(st.sampled_from(unique_placeholders)) text = draw(st.text(max_size=50)) doc.add_paragraph(f"{text} {placeholder}") doc_stream = io.BytesIO() doc.save(doc_stream) doc_stream.seek(0) docx_binary = doc_stream.read() return docx_binary, sorted(list(set(unique_placeholders))) # ============================================================================ # Export all strategies # ============================================================================ __all__ = [ 'placeholder_keys', 'text_with_placeholders', 'docx_with_placeholders', 'placeholder_mappings', 'valid_mappings', 'participant_data', 'docx_with_mappings_and_data', 'empty_docx', 'docx_with_duplicate_placeholders', ]