521 lines
17 KiB
Python
521 lines
17 KiB
Python
# -*- 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',
|
|
]
|