diff --git a/checks/beltline_validation_check.py b/checks/beltline_validation_check.py new file mode 100644 index 0000000..8bd321d --- /dev/null +++ b/checks/beltline_validation_check.py @@ -0,0 +1,213 @@ +import os +import json +import logging +import re +from typing import Dict, Any, Set, Tuple, Optional, List +from utils.check_helpers import record_skipped_type, prepare_skipped_result + +def run_check(config): + """ + Comprehensive Beltline Validation Check + + Validates beltline images with the new structure: + - Desktop: imagetype="desktop", variant="desktop", frames 0-71 (72 total) + - Mobile: imagetype="mobile", variant="mobile", frames 49-71 (23 total) + + Validation Requirements: + 1. Frame Count: Exactly 72 desktop frames (0-71) and 23 mobile frames (49-71) + 2. Frame Range Warnings: Mobile frames 0-48 should not exist + 3. Angle/Filename Consistency: JSON angle field must match filename frame number + 4. Variant Consistency: imagetype must equal variant field + + Expected config: + - working_dir (str): Directory where linkingrecord.json and images reside + - linkingrecord_filename (str): Name of the linking record file, default: linkingrecord.json + + Returns: + - "passed" if all validations pass + - "failed" if required frames missing or critical issues found + - Warnings for non-critical issues (unexpected frames, mismatches) + """ + + working_dir = config.get("working_dir", "working") + linkingrecord_filename = config.get("linkingrecord_filename", "linkingrecord.json") + linkingrecord_path = os.path.join(working_dir, linkingrecord_filename) + + if not os.path.exists(linkingrecord_path): + return { + "status": "error", + "error_message": f"Linking record '{linkingrecord_filename}' not found in {working_dir}." + } + + try: + with open(linkingrecord_path, 'r', encoding='utf-8') as f: + linkingrecord = json.load(f) + except json.JSONDecodeError as e: + return { + "status": "error", + "error_message": f"Invalid JSON in {linkingrecord_filename}: {str(e)}" + } + except Exception as e: + return { + "status": "error", + "error_message": f"Error reading {linkingrecord_filename}: {str(e)}" + } + + if "items" not in linkingrecord or not isinstance(linkingrecord["items"], list): + return { + "status": "error", + "error_message": "Invalid linkingrecord.json structure: 'items' missing or not a list." + } + + # Track skipped viewtype/imagetype combinations + skipped_types: Dict[str, Set[Tuple[str, Optional[str]]]] = {"beltline_validation_check": set()} + + # Find all beltline items (vehicleSelector with desktop/mobile imagetype) + desktop_items = [] + mobile_items = [] + warnings = [] + + for item in linkingrecord["items"]: + conditions = item.get("conditions", {}) + viewtype = conditions.get("viewtype") + imagetype = conditions.get("imagetype") + variant = conditions.get("variant") + + # Skip items with missing viewtype + if not viewtype: + logging.debug(f"Skipping item with missing viewtype: {conditions}") + continue + + # Only process vehicleSelector items + if viewtype != "vehicleSelector": + continue + + # Check for beltline items (desktop/mobile imagetype) + if imagetype in ["desktop", "mobile"]: + # Validate variant consistency + if variant != imagetype: + warnings.append(f"Inconsistent variant field: imagetype='{imagetype}' but variant='{variant}' (should match)") + + if imagetype == "desktop": + desktop_items.append(item) + elif imagetype == "mobile": + mobile_items.append(item) + else: + # Record unknown imagetype combinations for vehicleSelector + if imagetype is not None: + record_skipped_type(skipped_types, "beltline_validation_check", viewtype, imagetype) + + # If no beltline items found, this is likely not a beltline pack + if not desktop_items and not mobile_items: + if skipped_types["beltline_validation_check"]: + return prepare_skipped_result(skipped_types, "beltline_validation_check") + + return { + "status": "passed", + "details": { + "message": "No beltline items found in this pack.", + "pack_type": "Non-beltline pack" + } + } + + # Validate desktop frames (0-71, total 72) + desktop_validation = validate_frame_set(desktop_items, "desktop", expected_frames=set(range(72)), working_dir=working_dir) + + # Validate mobile frames (49-71, total 23) + mobile_validation = validate_frame_set(mobile_items, "mobile", expected_frames=set(range(49, 72)), working_dir=working_dir) + + # Combine all validation results + all_missing_frames = desktop_validation["missing_frames"] + mobile_validation["missing_frames"] + all_warnings = warnings + desktop_validation["warnings"] + mobile_validation["warnings"] + + # Determine overall status + if all_missing_frames: + result = { + "status": "failed", + "details": { + "message": f"Beltline validation failed - {len(all_missing_frames)} required frames missing.", + "missing_desktop_frames": desktop_validation["missing_frames"], + "missing_mobile_frames": mobile_validation["missing_frames"], + "desktop_frame_summary": f"{desktop_validation['found_count']}/72 desktop frames found", + "mobile_frame_summary": f"{mobile_validation['found_count']}/23 mobile frames found" + } + } + else: + result = { + "status": "passed", + "details": { + "message": "All required beltline frames found and validated successfully.", + "desktop_frame_summary": f"{desktop_validation['found_count']}/72 desktop frames found", + "mobile_frame_summary": f"{mobile_validation['found_count']}/23 mobile frames found" + } + } + + # Add warnings if any exist + if all_warnings: + result["details"]["warnings"] = all_warnings + result["details"]["warning_count"] = len(all_warnings) + + # Add skipped types if any exist + if skipped_types["beltline_validation_check"]: + result["details"]["skipped_types_message"] = f"{len(skipped_types['beltline_validation_check'])} unknown viewtype/imagetype combinations were skipped." + result["details"]["skipped_types"] = [ + {"viewtype": vt, "imagetype": it if it is not None else "None"} + for vt, it in skipped_types["beltline_validation_check"] + ] + + return result + +def validate_frame_set(items: List[Dict], variant_type: str, expected_frames: Set[int], working_dir: str) -> Dict[str, Any]: + """ + Validate a set of beltline frames (desktop or mobile). + + Args: + items: List of linkingrecord items for this variant + variant_type: "desktop" or "mobile" for logging + expected_frames: Set of expected frame numbers (0-71 for desktop, 49-71 for mobile) + working_dir: Directory containing the images + + Returns: + Dict with validation results + """ + found_frames = set() + warnings = [] + filename_pattern = r'(vsd|vsm)_(\d+)_\d+\.(avif|AVIF)' + + for item in items: + records = item.get("records", []) + + for record in records: + angle = record.get("angle") + assets = record.get("assets", []) + + for asset in assets: + filename = asset.get("filename", "") + + if filename: + # Extract frame number from filename + match = re.search(filename_pattern, filename) + if match: + filename_frame = int(match.group(2)) + found_frames.add(filename_frame) + + # Validate angle/filename consistency + if angle is not None and angle != filename_frame: + warnings.append(f"Angle/filename mismatch: angle={angle} but filename='{filename}' suggests frame {filename_frame}") + + # Check for unexpected mobile frames 0-48 + if variant_type == "mobile" and filename_frame < 49: + warnings.append(f"Unexpected mobile frame {filename_frame} found (mobile frames should only be 49-71)") + else: + warnings.append(f"Could not parse frame number from filename: '{filename}'") + + # Calculate missing frames + missing_frames = sorted(list(expected_frames - found_frames)) + found_count = len(found_frames) + + return { + "missing_frames": missing_frames, + "found_count": found_count, + "warnings": warnings, + "found_frames": sorted(list(found_frames)) + } \ No newline at end of file diff --git a/checks/extra_carousel_validation_check.py b/checks/extra_carousel_validation_check.py new file mode 100644 index 0000000..98b9866 --- /dev/null +++ b/checks/extra_carousel_validation_check.py @@ -0,0 +1,235 @@ +import os +import json +import logging +from typing import Dict, Any, List, Tuple + +def run_check(config): + """ + Enhanced Extra Carousel Images Validation Check + + Validates that carousel/extra images match their WERS codes and meet coverage requirements: + - BAU: 0-49% fail, 50-99% warning, 100% pass + - MEC: 0-99% fail, 100% pass + + Expected config: + - working_dir (str): Directory where linkingrecord.json and images reside + - linkingrecord_filename (str): Name of the linking record file, default: linkingrecord.json + + Returns: + - "passed" if coverage meets requirements + - "warning" for BAU packs with 50-99% coverage + - "failed" if coverage below requirements or missing features + - "error" for structural issues + """ + + working_dir = config.get("working_dir", "working") + linkingrecord_filename = config.get("linkingrecord_filename", "linkingrecord.json") + linkingrecord_path = os.path.join(working_dir, linkingrecord_filename) + + if not os.path.exists(linkingrecord_path): + return { + "status": "error", + "error_message": f"Linking record '{linkingrecord_filename}' not found in {working_dir}." + } + + try: + with open(linkingrecord_path, 'r', encoding='utf-8') as f: + linkingrecord = json.load(f) + except json.JSONDecodeError as e: + return { + "status": "error", + "error_message": f"Invalid JSON in {linkingrecord_filename}: {str(e)}" + } + except Exception as e: + return { + "status": "error", + "error_message": f"Error reading {linkingrecord_filename}: {str(e)}" + } + + if "items" not in linkingrecord or not isinstance(linkingrecord["items"], list): + return { + "status": "error", + "error_message": "Invalid linkingrecord.json structure: 'items' missing or not a list." + } + + # Detect pack type (MEC vs BAU) + is_mec = any( + item.get("conditions", {}).get("experienceCondition") == "2d-background" + for item in linkingrecord["items"] + ) + pack_type = "MEC" if is_mec else "BAU" + + # Find all carousel/extra items + carousel_extra_items = [] + for item_index, item in enumerate(linkingrecord["items"]): + conditions = item.get("conditions", {}) + viewtype = conditions.get("viewtype") + imagetype = conditions.get("imagetype") + + if viewtype == "carousel" and imagetype == "extra": + carousel_extra_items.append((item_index, item)) + + # If no carousel/extra items found, this pack doesn't use extra carousel images + if not carousel_extra_items: + return { + "status": "passed", + "details": { + "message": "No carousel/extra items found - validation not applicable.", + "pack_type": pack_type, + "validation_type": "not_applicable" + } + } + + # Validate each carousel/extra item + record_results = [] + all_features = [] + all_matched_features = [] + + for item_index, item in carousel_extra_items: + records = item.get("records", []) + + for record_index, record in enumerate(records): + result = validate_record_features(record, working_dir, item_index, record_index) + if result["status"] == "error": + return { + "status": "error", + "error_message": result["error_message"] + } + + record_results.append(result) + all_features.extend(result["features"]) + all_matched_features.extend(result["matched_features"]) + + # Calculate overall coverage + if not all_features: + return { + "status": "error", + "error_message": "No features found in any carousel/extra records. Features array cannot be empty." + } + + total_features = len(all_features) + matched_count = len(all_matched_features) + overall_coverage = (matched_count / total_features) * 100 + + # Apply validation rules based on pack type + if pack_type == "MEC": + # MEC: Must have 100% coverage + if overall_coverage < 100: + status = "failed" + message = f"MEC pack requires 100% extra carousel coverage, found {overall_coverage:.1f}%" + else: + status = "passed" + message = f"MEC pack extra carousel validation passed - 100% coverage achieved" + else: + # BAU: 0-49% fail, 50-99% warning, 100% pass + if overall_coverage < 50: + status = "failed" + message = f"BAU pack extra carousel coverage too low: {overall_coverage:.1f}% (minimum 50% required)" + elif overall_coverage < 100: + status = "warning" + message = f"BAU pack extra carousel coverage at {overall_coverage:.1f}% - consider improving to 100%" + else: + status = "passed" + message = f"BAU pack extra carousel validation passed - 100% coverage achieved" + + # Compile missing features summary + missing_features = [] + for result in record_results: + missing_features.extend(result["missing_features"]) + + # Build comprehensive result + result = { + "status": status, + "details": { + "message": message, + "pack_type": pack_type, + "overall_coverage": round(overall_coverage, 1), + "total_features_across_all_records": total_features, + "matched_features_count": matched_count, + "validation_summary": f"{matched_count}/{total_features} features matched ({overall_coverage:.1f}% coverage)", + "record_results": record_results + } + } + + # Add missing features summary if any + if missing_features: + result["details"]["missing_features_summary"] = sorted(list(set(missing_features))) + + return result + +def validate_record_features(record: Dict[str, Any], working_dir: str, item_index: int, record_index: int) -> Dict[str, Any]: + """ + Validate features in a single record against available image files. + + Args: + record: Single record from carousel/extra item + working_dir: Directory containing image files + item_index: Index of the parent item + record_index: Index of this record within the item + + Returns: + Dict with validation results for this record + """ + features = record.get("features", []) + + # Check for empty or missing features + if not features: + return { + "status": "error", + "error_message": f"Empty or missing 'features' array in carousel/extra record (item {item_index}, record {record_index})" + } + + # Get all image files in working directory + available_files = [] + try: + for root, dirs, files in os.walk(working_dir): + for file in files: + if file.lower().endswith(('.jpg', '.jpeg', '.png', '.avif')): + available_files.append(file) + except Exception as e: + return { + "status": "error", + "error_message": f"Error scanning for image files: {str(e)}" + } + + # Check each feature for matching images using substring search + matched_features = [] + found_filenames = [] + missing_features = [] + + for feature in features: + # Case-sensitive substring search in filenames + matching_files = [f for f in available_files if feature in f] + + if matching_files: + matched_features.append(feature) + found_filenames.extend(matching_files) + else: + missing_features.append(feature) + + # Calculate coverage for this record + coverage = (len(matched_features) / len(features)) * 100 if features else 0 + + return { + "status": "success", + "record_index": record_index, + "item_index": item_index, + "features": features, + "coverage": round(coverage, 1), + "matched_features": matched_features, + "missing_features": missing_features, + "found_filenames": sorted(list(set(found_filenames))) # Remove duplicates and sort + } + +def check_feature_in_filename(feature: str, filename: str) -> bool: + """ + Simple case-sensitive substring search. + + Args: + feature: WERS code to search for + filename: Filename to search in + + Returns: + bool: True if feature appears anywhere in filename + """ + return feature in filename \ No newline at end of file diff --git a/checks/file_size_check.py b/checks/file_size_check.py index bca666a..a7dceec 100755 --- a/checks/file_size_check.py +++ b/checks/file_size_check.py @@ -93,23 +93,23 @@ def run_check(config): ) pack_type = "MEC" if is_mec else "BAU" - # Max sizes in KB + # Max sizes in KB (updated limits with 25% advisory buffer system) max_sizes_kb = { - "Base Exterior Images": 750, - "Base Interior Images": 1200, - "Engine and Transmission Images": 1024, - "Interior Layered Option Images": 1024, - "Exterior Layered Option Images": 600, - "Option Carousel Images": 500, - "Powertrain Image": 400, - "Showroom Images": 300, - "Colour Chips": 50, - "Bodystyle Images": 200, - "Series Images": 300, - "Trim Images": 700, - "Lifestyle Images": 500, # 500KB for lifestyle - "Inventory Images": 1024, # 1MB for inventory - "Beltline Images": 800 # 800KB for beltline AVIF images + "Base Exterior Images": 750, # No change + "Base Interior Images": 1024, # 1MB binary (was 1200) + "Engine and Transmission Images": 1024, # No change + "Interior Layered Option Images": 1024, # No change + "Exterior Layered Option Images": 750, # Increased from 600 + "Option Carousel Images": 500, # No change + "Powertrain Images": 600, # Increased from 400, renamed from singular + "Showroom Images": 200, # Decreased from 300 + "Colour Chips": 30, # Decreased from 50 + "Bodystyle Images": 50, # Decreased from 200 + "Series Images": 50, # Decreased from 300 + "Trim Images": 800, # Increased from 700 + "Lifestyle Images": 400, # Decreased from 500 + "Inventory Images": 800, # Decreased from 1024 + "Beltline Images": 800 # No change (typo corrected) } def get_asset_type(conditions): @@ -122,8 +122,13 @@ def run_check(config): return "Interior Layered Option Images" if viewtype == "exterior" and imagetype == "layeroptext": return "Exterior Layered Option Images" - if experience == "2d-background" and viewtype in ["exterior", "interior"] and imagetype is None: - return "Engine and Transmission Images" + # Check for MEC powertrain images (2d-background + powertrain in filename) + if experience == "2d-background": + if viewtype in ["exterior", "interior"] and imagetype is None: + return "Engine and Transmission Images" + # MEC carousel powertrain should also use Powertrain Images limit + if viewtype == "carousel" and imagetype == "powertrain": + return "Powertrain Images" if viewtype == "exterior" and imagetype is None: return "Base Exterior Images" if viewtype == "interior" and imagetype is None: @@ -132,7 +137,7 @@ def run_check(config): if imagetype == "extra": return "Option Carousel Images" if imagetype == "powertrain": - return "Powertrain Image" + return "Powertrain Images" # Updated to plural to match new limit key if imagetype == "colour": return "Colour Chips" if imagetype == "bodystyle": @@ -147,14 +152,16 @@ def run_check(config): return "Lifestyle Images" if viewtype == "inventory": return "Inventory Images" - if viewtype == "vehicleSelector" and imagetype == "beltline": + if viewtype == "vehicleSelector" and imagetype in ["desktop", "mobile"]: return "Beltline Images" # No match return None - # Use a dictionary to avoid duplicates (key by filename) - failed_images_dict = {} + # Three-tier validation system: compliant, advisory warnings, size violations + compliant_images = {} + advisory_warnings = {} + size_violations = {} # Track skipped viewtype/imagetype combinations skipped_types: Dict[str, Set[Tuple[str, Optional[str]]]] = {"file_size_check": set()} @@ -179,6 +186,7 @@ def run_check(config): max_size_kb = max_sizes_kb[asset_type] max_size_bytes = max_size_kb * 1024 + advisory_threshold_bytes = int(max_size_bytes * 1.25) # 25% buffer zone records = item.get("records", []) for record_index, record in enumerate(records): @@ -200,53 +208,113 @@ def run_check(config): logging.warning(f"Could not get size of file {file_path}: {str(e)}") continue - if file_size > max_size_bytes: - if filename not in failed_images_dict: - failed_images_dict[filename] = { + # Calculate percentages and categorize + file_size_kb = round(file_size / 1024, 2) + percentage_of_limit = round((file_size / max_size_bytes) * 100, 2) + + # Three-tier validation + if file_size <= max_size_bytes: + # Compliant: 0-100% of limit + if filename not in compliant_images: + compliant_images[filename] = { "filename": filename, "asset_type": asset_type, - "file_size_bytes": file_size, - "max_size_bytes": max_size_bytes, - "file_size_kb": round(file_size / 1024, 2), - "max_size_kb": max_size_kb, - "over_limit_kb": round((file_size - max_size_bytes) / 1024, 2), - "over_limit_percentage": round(((file_size - max_size_bytes) / max_size_bytes) * 100, 2) + "file_size_kb": file_size_kb, + "limit_kb": max_size_kb, + "percentage_of_limit": percentage_of_limit, + "status": "compliant" + } + elif file_size <= advisory_threshold_bytes: + # Advisory Warning: 101-125% of limit + if filename not in advisory_warnings: + advisory_warnings[filename] = { + "filename": filename, + "asset_type": asset_type, + "file_size_kb": file_size_kb, + "limit_kb": max_size_kb, + "percentage_over": round(percentage_of_limit - 100, 1), + "percentage_of_limit": percentage_of_limit, + "status": "advisory", + "message": "File size slightly exceeds recommended limit but is within acceptable tolerance. Consider optimizing for better performance." + } + else: + # Size Violation: 126%+ of limit + if filename not in size_violations: + size_violations[filename] = { + "filename": filename, + "asset_type": asset_type, + "file_size_kb": file_size_kb, + "limit_kb": max_size_kb, + "percentage_over": round(percentage_of_limit - 100, 1), + "percentage_of_limit": percentage_of_limit, + "status": "violation", + "message": "File size significantly exceeds maximum limit and requires optimization before final delivery." } - if failed_images_dict: - result = { - "status": "failed", - "details": { - "message": f"{len(failed_images_dict)} files exceed the maximum allowed size.", - "failed_images": list(failed_images_dict.values()), - "pack_type": pack_type - } - } - - # Add skipped types to the result if any were found - if skipped_types["file_size_check"]: - result["details"]["skipped_types_message"] = f"{len(skipped_types['file_size_check'])} unknown viewtype/imagetype combinations were skipped." - result["details"]["skipped_types"] = [ - {"viewtype": vt, "imagetype": it if it is not None else "None"} - for vt, it in skipped_types["file_size_check"] - ] - - return result + # Generate counts for summary + compliant_count = len(compliant_images) + advisory_count = len(advisory_warnings) + violation_count = len(size_violations) + total_files = compliant_count + advisory_count + violation_count - # If we skipped any types, add that to the successful result - if skipped_types["file_size_check"]: - result = prepare_skipped_result(skipped_types, "file_size_check") - result["details"]["pack_type"] = pack_type - return result + # Determine overall status (fail only if there are violations) + if violation_count > 0: + status = "failed" + status_message = f"File size validation failed - {violation_count} files significantly exceed size limits" + elif advisory_count > 0: + status = "passed" + status_message = f"File size validation passed with {advisory_count} advisory warnings" + else: + status = "passed" + status_message = "All present images are within recommended file size limits" - return { - "status": "passed", + # Build comprehensive result + result = { + "status": status, "details": { - "message": "All present images are within the allowed file size.", + "message": status_message, + "validation_summary": f"{compliant_count} compliant, {advisory_count} advisory warnings, {violation_count} violations", "pack_type": pack_type } } + # Add compliant images if any + if compliant_images: + result["details"]["compliant_images"] = list(compliant_images.values()) + + # Add advisory warnings if any (these don't cause failure) + if advisory_warnings: + result["details"]["advisory_warnings"] = list(advisory_warnings.values()) + + # Add size violations if any (these cause failure) + if size_violations: + result["details"]["size_violations"] = list(size_violations.values()) + + # Add detailed statistics + result["details"]["statistics"] = { + "total_files_checked": total_files, + "compliant_files": compliant_count, + "advisory_warnings": advisory_count, + "size_violations": violation_count, + "compliance_rate": round((compliant_count / total_files) * 100, 1) if total_files > 0 else 100 + } + + # Add skipped types if any exist + if skipped_types["file_size_check"]: + result["details"]["skipped_types_message"] = f"{len(skipped_types['file_size_check'])} unknown viewtype/imagetype combinations were skipped." + result["details"]["skipped_types"] = [ + {"viewtype": vt, "imagetype": it if it is not None else "None"} + for vt, it in skipped_types["file_size_check"] + ] + + # Handle case where no files were processed + if total_files == 0: + if skipped_types["file_size_check"]: + return prepare_skipped_result(skipped_types, "file_size_check") + result["details"]["message"] = "No files were found to validate for file size" + + return result + except Exception as e: # Catch any unexpected exceptions and return detailed error information error_msg = f"Unexpected error in file size check: {str(e)}" diff --git a/checks/image_format_check.py b/checks/image_format_check.py index 5339526..7cfac56 100644 --- a/checks/image_format_check.py +++ b/checks/image_format_check.py @@ -40,13 +40,18 @@ def run_check(config): "status": "error", "error_message": "Invalid linkingrecord.json structure: 'items' missing or not a list." } - - # Define format requirements + + # Detect if this is a MEC pack (contains experienceCondition="2d-background") + is_mec = any( + item.get("conditions", {}).get("experienceCondition") == "2d-background" + for item in linkingrecord["items"] + ) + pack_type = "MEC" if is_mec else "BAU" + + # Define format requirements (base assets handled separately) # Format: (viewtype, imagetype): expected_format format_requirements = { - # For BAU - ('exterior', None): 'PNG', - ('interior', None): 'PNG', + # Base assets ('exterior', None) and ('interior', None) are handled with flexible logic below ('exterior', 'layeroptext'): 'PNG', ('interior', 'layeroptint'): 'PNG', ('carousel', 'extra'): 'JPEG', @@ -59,40 +64,72 @@ def run_check(config): # Adding lifestyle and inventory requirements ('lifestyle', None): 'JPEG', ('inventory', None): 'JPEG', - # Adding beltline requirements - ('vehicleSelector', 'beltline'): 'AVIF', + # Adding beltline requirements (desktop and mobile variants) + ('vehicleSelector', 'desktop'): 'AVIF', + ('vehicleSelector', 'mobile'): 'AVIF', } + + def is_base_asset(viewtype, imagetype): + """Check if this is a base exterior or interior asset.""" + return (viewtype == 'exterior' and imagetype is None) or (viewtype == 'interior' and imagetype is None) + + def validate_base_asset_format(filename, actual_format, pack_type, viewtype): + """ + Validate base asset format with flexible rules: + - MEC base assets: Strict JPG only (fail if PNG) + - BAU base assets: JPG preferred, PNG acceptable with warning + Returns: (is_valid, warning_message) + """ + if pack_type == "MEC": + # MEC base assets must be JPG only + if actual_format != "JPEG": + return False, None + return True, None + else: # BAU pack + # BAU base assets: JPG preferred, PNG acceptable with warning + if actual_format == "JPEG": + return True, None + elif actual_format == "PNG": + return True, f"PNG format found but JPG format is preferred for {viewtype} base assets. Please consider changing this to JPG to match business requirements." + else: + return False, None # Use a dictionary to avoid duplicates (keyed by filename) failed_images_dict = {} - + warnings_list = [] + # Track skipped viewtype/imagetype combinations skipped_types: Dict[str, Set[Tuple[str, Optional[str]]]] = {"image_format_check": set()} - + for item in linkingrecord["items"]: conditions = item.get("conditions", {}) viewtype = conditions.get("viewtype") imagetype = conditions.get("imagetype") - + # Skip items with missing viewtype if not viewtype: logging.debug(f"Skipping item with missing viewtype: {conditions}") continue - - # Find the applicable format requirement - key = (viewtype, imagetype) - expected_format = None - - if key in format_requirements: - expected_format = format_requirements[key] - # Try with None imagetype as fallback - elif (viewtype, None) in format_requirements: - expected_format = format_requirements[(viewtype, None)] - - # If no requirement is found, record it and skip this item - if expected_format is None: - record_skipped_type(skipped_types, "image_format_check", viewtype, imagetype) - continue + + # Check if this is a base asset that needs special handling + if is_base_asset(viewtype, imagetype): + # Base assets use flexible validation logic + expected_format = None # Will be handled in the validation function + else: + # Non-base assets use the standard format requirements + key = (viewtype, imagetype) + expected_format = None + + if key in format_requirements: + expected_format = format_requirements[key] + # Try with None imagetype as fallback + elif (viewtype, None) in format_requirements: + expected_format = format_requirements[(viewtype, None)] + + # If no requirement is found, record it and skip this item + if expected_format is None: + record_skipped_type(skipped_types, "image_format_check", viewtype, imagetype) + continue records = item.get("records", []) for record in records: @@ -111,26 +148,52 @@ def run_check(config): try: with Image.open(image_path) as img: actual_format = img.format - - # PIL reports "JPEG" for JPG files - if actual_format != expected_format: - # Only add an entry if this filename not yet in the dict - if filename not in failed_images_dict: - failed_images_dict[filename] = { + + if is_base_asset(viewtype, imagetype): + # Use flexible validation for base assets + is_valid, warning_message = validate_base_asset_format(filename, actual_format, pack_type, viewtype) + + if not is_valid: + # Only add an entry if this filename not yet in the dict + if filename not in failed_images_dict: + expected_desc = "JPEG" if pack_type == "MEC" else "JPEG (preferred) or PNG (acceptable)" + failed_images_dict[filename] = { + "filename": filename, + "viewtype": viewtype, + "imagetype": imagetype, + "expected_format": expected_desc, + "actual_format": actual_format, + "pack_type": pack_type + } + elif warning_message: + # Add warning but don't fail + warnings_list.append({ "filename": filename, - "viewtype": viewtype, - "imagetype": imagetype, - "expected_format": expected_format, - "actual_format": actual_format - } + "message": warning_message + }) + else: + # Standard validation for non-base assets + # PIL reports "JPEG" for JPG files + if actual_format != expected_format: + # Only add an entry if this filename not yet in the dict + if filename not in failed_images_dict: + failed_images_dict[filename] = { + "filename": filename, + "viewtype": viewtype, + "imagetype": imagetype, + "expected_format": expected_format, + "actual_format": actual_format + } + except Exception as e: # If we can't open the image, mark it as failed if filename not in failed_images_dict: + expected_desc = expected_format if expected_format else ("JPEG" if pack_type == "MEC" and is_base_asset(viewtype, imagetype) else "Unknown") failed_images_dict[filename] = { "filename": filename, "viewtype": viewtype, "imagetype": imagetype, - "expected_format": expected_format, + "expected_format": expected_desc, "actual_format": f"Error: {str(e)}" } @@ -140,10 +203,15 @@ def run_check(config): "status": "failed", "details": { "message": "Some images are not in the expected file format.", - "failed_images": list(failed_images_dict.values()) + "failed_images": list(failed_images_dict.values()), + "pack_type": pack_type } } - + + # Add warnings to failed result if any exist + if warnings_list: + result["details"]["warnings"] = warnings_list + # Add skipped types to the result if any were found if skipped_types["image_format_check"]: result["details"]["skipped_types_message"] = f"{len(skipped_types['image_format_check'])} unknown viewtype/imagetype combinations were skipped." @@ -151,16 +219,32 @@ def run_check(config): {"viewtype": vt, "imagetype": it if it is not None else "None"} for vt, it in skipped_types["image_format_check"] ] - + return result - - # If we skipped any types, add that to the successful result - if skipped_types["image_format_check"]: - return prepare_skipped_result(skipped_types, "image_format_check") - - return { + + # No failures - construct success result + base_message = "All present images match their required file format." + + # If we have warnings, it's still a pass but with warnings + result = { "status": "passed", "details": { - "message": "All present images match their required file format." + "message": base_message, + "pack_type": pack_type } - } \ No newline at end of file + } + + # Add warnings to successful result if any exist + if warnings_list: + result["details"]["warnings"] = warnings_list + result["details"]["message"] = f"{base_message} Some recommendations noted below." + + # If we skipped any types, add that to the result + if skipped_types["image_format_check"]: + result["details"]["skipped_types_message"] = f"{len(skipped_types['image_format_check'])} unknown viewtype/imagetype combinations were skipped." + result["details"]["skipped_types"] = [ + {"viewtype": vt, "imagetype": it if it is not None else "None"} + for vt, it in skipped_types["image_format_check"] + ] + + return result \ No newline at end of file diff --git a/checks/image_resolution_check.py b/checks/image_resolution_check.py index 7806d1a..f42c741 100755 --- a/checks/image_resolution_check.py +++ b/checks/image_resolution_check.py @@ -74,8 +74,9 @@ def run_check(config): ("carousel", "bodystyle"): (678, 381), ("carousel", "series"): (678, 381), ("carousel", "trim"): (678, 381), - # Beltline resolution requirements (desktop and mobile) - ("vehicleSelector", "beltline"): (1920, 842), # Default to desktop + # Beltline resolution requirements (desktop and mobile variants) + ("vehicleSelector", "desktop"): (1920, 842), + ("vehicleSelector", "mobile"): (640, 428), } mec_map = { @@ -92,8 +93,9 @@ def run_check(config): ("carousel", "bodystyle"): (678, 381), ("carousel", "series"): (678, 381), ("carousel", "trim"): (678, 381), - # Beltline resolution requirements (desktop and mobile) - ("vehicleSelector", "beltline"): (1920, 842), # Default to desktop + # Beltline resolution requirements (desktop and mobile variants) + ("vehicleSelector", "desktop"): (1920, 842), + ("vehicleSelector", "mobile"): (640, 428), } # ------------------------------------------------------------------------- @@ -141,15 +143,7 @@ def run_check(config): ) key = (viewtype, imagetype) - - # Special handling for beltline images with mobile variant - if viewtype == "vehicleSelector" and imagetype == "beltline": - if variant == "mobile": - return (640, 428), used_experience_type - else: - # Default to desktop resolution for beltline - return (1920, 842), used_experience_type - + # If the exact key isn't in the map, try a fallback without imagetype if key not in resolution_map: fallback_key = (viewtype, None) diff --git a/checks/layer_depth_check.py b/checks/layer_depth_check.py index 50a77c2..a2e423f 100644 --- a/checks/layer_depth_check.py +++ b/checks/layer_depth_check.py @@ -119,7 +119,7 @@ def run_check(config): elif viewtype == "carousel" and imagetype not in ["powertrain", "extra", "colour", "bodystyle", "series", "trim"]: record_skipped_type(skipped_types, "layer_depth_check", viewtype, imagetype) continue - elif viewtype == "vehicleSelector" and imagetype not in ["beltline"]: + elif viewtype == "vehicleSelector" and imagetype not in ["desktop", "mobile"]: record_skipped_type(skipped_types, "layer_depth_check", viewtype, imagetype) continue diff --git a/checks/special_requirements_mec_bau.py b/checks/special_requirements_mec_bau.py index de02798..e0d9c6a 100755 --- a/checks/special_requirements_mec_bau.py +++ b/checks/special_requirements_mec_bau.py @@ -15,9 +15,8 @@ def run_check(config): - In its records, look for angle=30 and an asset filename containing "powertrain" and "mec_30_0". - If not found, fail. * If BAU (no "2d-background"): - - Find carousel/powertrain item. - - Ensure it has at least one asset in its records. - - If not found, fail. + - Validate _mec filename placement rules (no carousel/powertrain requirements). + - Pass if _mec assets are properly placed or not present. * Unknown viewtype/imagetype combinations are gracefully skipped and recorded. """ @@ -63,7 +62,7 @@ def run_check(config): record_skipped_type(skipped_types, "special_requirements_mec_bau", viewtype, imagetype) elif viewtype in ["exterior", "interior"] and imagetype not in [None, "layeroptint", "layeroptext", "showroom"]: record_skipped_type(skipped_types, "special_requirements_mec_bau", viewtype, imagetype) - elif viewtype == "vehicleSelector" and imagetype not in ["beltline"]: + elif viewtype == "vehicleSelector" and imagetype not in ["desktop", "mobile"]: record_skipped_type(skipped_types, "special_requirements_mec_bau", viewtype, imagetype) # Detect if MEC scenario: @@ -200,7 +199,29 @@ def run_check(config): break if not has_mec_asset: section_validation_warnings.append(f"2d-background {description} section exists but no assets with '_mec' found in filenames") - + + # Universal _mec filename validation: check ALL items to ensure _mec assets are only in 2d-background sections + for item in items: + conditions = item.get("conditions", {}) + viewtype = conditions.get("viewtype") + imagetype = conditions.get("imagetype") + experience_condition = conditions.get("experienceCondition") + + # Skip items without viewtype + if not viewtype: + continue + + # Check all assets in this item + for record in item.get("records", []): + for asset in record.get("assets", []): + filename = asset.get("filename", "") + if "_mec" in filename: + # Asset has _mec in filename - it should ONLY be in 2d-background sections + if experience_condition != "2d-background": + imagetype_display = imagetype if imagetype is not None else "None" + experience_display = experience_condition if experience_condition is not None else "None" + section_validation_errors.append(f"Asset '{filename}' found in regular viewtype='{viewtype}', imagetype='{imagetype_display}' section (should only be in 2d-background sections)") + # If there are section validation errors, fail the check if section_validation_errors: result = { @@ -249,71 +270,60 @@ def run_check(config): else: # BAU scenario: - # Need to find a carousel/powertrain item with at least one asset - carousel_powertrain_items = [ - item for item in items - if item.get("conditions", {}).get("viewtype") == "carousel" - and item.get("conditions", {}).get("imagetype") == "powertrain" - ] + # No carousel/powertrain requirements - only validate _mec filename placement rules - if not carousel_powertrain_items: + # Universal _mec filename validation for BAU: check ALL items to ensure _mec assets are only in 2d-background sections + bau_validation_errors = [] + for item in items: + conditions = item.get("conditions", {}) + viewtype = conditions.get("viewtype") + imagetype = conditions.get("imagetype") + experience_condition = conditions.get("experienceCondition") + + # Skip items without viewtype + if not viewtype: + continue + + # Check all assets in this item + for record in item.get("records", []): + for asset in record.get("assets", []): + filename = asset.get("filename", "") + if "_mec" in filename: + # Asset has _mec in filename - it should ONLY be in 2d-background sections + if experience_condition != "2d-background": + imagetype_display = imagetype if imagetype is not None else "None" + experience_display = experience_condition if experience_condition is not None else "None" + bau_validation_errors.append(f"Asset '{filename}' found in regular viewtype='{viewtype}', imagetype='{imagetype_display}' section (should only be in 2d-background sections)") + + # If there are BAU validation errors, fail the check + if bau_validation_errors: result = { "status": "failed", "details": { - "message": "BAU scenario: No carousel/powertrain section found.", - "pack_type": pack_type + "message": "BAU scenario: _mec filename validation failed.", + "pack_type": pack_type, + "bau_validation_errors": bau_validation_errors } } - - # Add skipped types to the result if any were found + if skipped_types["special_requirements_mec_bau"]: result["details"]["skipped_types_message"] = f"{len(skipped_types['special_requirements_mec_bau'])} unknown viewtype/imagetype combinations were skipped." result["details"]["skipped_types"] = [ {"viewtype": vt, "imagetype": it if it is not None else "None"} for vt, it in skipped_types["special_requirements_mec_bau"] ] - - return result - - # Check for at least one asset - found_asset = False - for item in carousel_powertrain_items: - records = item.get("records", []) - for record in records: - assets = record.get("assets", []) - if assets: - found_asset = True - break - if found_asset: - break - - if not found_asset: - result = { - "status": "failed", - "details": { - "message": "BAU scenario: carousel/powertrain found but no assets present.", - "pack_type": pack_type - } - } - - # Add skipped types to the result if any were found - if skipped_types["special_requirements_mec_bau"]: - result["details"]["skipped_types_message"] = f"{len(skipped_types['special_requirements_mec_bau'])} unknown viewtype/imagetype combinations were skipped." - result["details"]["skipped_types"] = [ - {"viewtype": vt, "imagetype": it if it is not None else "None"} - for vt, it in skipped_types["special_requirements_mec_bau"] - ] - + return result + # BAU pack passes - no carousel/powertrain requirements, _mec validation passed result = { "status": "passed", "details": { - "message": "BAU scenario: carousel/powertrain section with assets found.", + "message": "BAU scenario: _mec filename validation passed. No carousel/powertrain requirements.", "pack_type": pack_type } } - + # If we skipped any types, add that to the successful result if skipped_types["special_requirements_mec_bau"]: result["details"]["skipped_types_message"] = f"{len(skipped_types['special_requirements_mec_bau'])} unknown viewtype/imagetype combinations were skipped." @@ -321,5 +331,5 @@ def run_check(config): {"viewtype": vt, "imagetype": it if it is not None else "None"} for vt, it in skipped_types["special_requirements_mec_bau"] ] - + return result \ No newline at end of file diff --git a/checks/unzip_and_verify_check.py b/checks/unzip_and_verify_check.py index 6fa5426..22442bf 100755 --- a/checks/unzip_and_verify_check.py +++ b/checks/unzip_and_verify_check.py @@ -2,6 +2,112 @@ import os import zipfile import shutil import json +import re + +def scan_for_extraneous_files(working_dir): + """ + Recursively scan the working directory for files that are not in the allowed whitelist. + + Args: + working_dir (str): Directory to scan + + Returns: + list: List of dictionaries containing information about extraneous files + """ + # Define allowed file extensions (case-insensitive) + allowed_extensions = {'.jpg', '.jpeg', '.png', '.avif', '.json'} + + extraneous_files = [] + + # Walk through all directories and files recursively + for root, dirs, files in os.walk(working_dir): + for file in files: + file_path = os.path.join(root, file) + + # Get the file extension (case-insensitive) + _, ext = os.path.splitext(file.lower()) + + # Check if the extension is in the allowed list + if ext not in allowed_extensions: + # Calculate relative path from working_dir for cleaner reporting + relative_path = os.path.relpath(file_path, working_dir) + + # Special handling for files that start with . but have no extension (like .DS_Store) + if not ext and file.startswith('.'): + display_extension = file.lower() # Show full filename for hidden files + else: + display_extension = ext if ext else "(no extension)" + + extraneous_files.append({ + "path": relative_path, + "filename": file, + "extension": display_extension, + "full_path": file_path, + "size_bytes": os.path.getsize(file_path) if os.path.exists(file_path) else 0 + }) + + return extraneous_files + +def validate_linkingrecord_header(linkingrecord_data): + """ + Validate the header section of linkingrecord.json for required field formats. + + Args: + linkingrecord_data (dict): The parsed linkingrecord JSON data + + Returns: + tuple: (is_valid: bool, error_details: list) + """ + errors = [] + + # Extract header fields + market = linkingrecord_data.get("market", "") + model = linkingrecord_data.get("model", "") + year = linkingrecord_data.get("year", "") + + # Get numericalMmy from header sub-object + header = linkingrecord_data.get("header", {}) + numerical_mmy = header.get("numericalMmy", "") + + # Validate market (exactly 3 alphabetic characters) + if not isinstance(market, str) or not re.match(r'^[A-Z]{3}$', market): + errors.append({ + "field": "market", + "value": market, + "error": "Must be exactly 3 alphabetic characters (A-Z)" + }) + + # Validate model (alphanumeric capitals only) + if not isinstance(model, str) or not re.match(r'^[A-Z0-9]+$', model): + errors.append({ + "field": "model", + "value": model, + "error": "Must contain only capital letters and numbers" + }) + + # Validate year (exactly 3 digits) + if not isinstance(year, str) or not re.match(r'^[0-9]{3}$', year): + errors.append({ + "field": "year", + "value": year, + "error": "Must be exactly 3 digits representing the year" + }) + + # Validate numericalMmy (no dots allowed) + if isinstance(numerical_mmy, str) and '.' in numerical_mmy: + errors.append({ + "field": "numericalMmy", + "value": numerical_mmy, + "error": "Must not contain dot characters" + }) + elif not isinstance(numerical_mmy, str): + errors.append({ + "field": "numericalMmy", + "value": numerical_mmy, + "error": "Must be a string value" + }) + + return len(errors) == 0, errors def run_check(config): # We expect config to contain: @@ -74,7 +180,7 @@ def run_check(config): if expected_file.lower().endswith('.json'): try: with open(expected_path, 'r', encoding='utf-8') as f: - json.load(f) + linkingrecord_data = json.load(f) except json.JSONDecodeError as e: return { "status": "failed", @@ -113,10 +219,35 @@ def run_check(config): } } + # Validate header fields if this is a linkingrecord.json file + if expected_file.lower() == 'linkingrecord.json': + is_valid, header_errors = validate_linkingrecord_header(linkingrecord_data) + if not is_valid: + return { + "status": "failed", + "details": { + "message": f"Linkingrecord header validation failed - {len(header_errors)} header field errors found", + "header_validation_errors": header_errors + } + } + + # Scan for extraneous files (files not in the allowed whitelist) + extraneous_files = scan_for_extraneous_files(working_dir) + if extraneous_files: + return { + "status": "failed", + "details": { + "message": "Extraneous files found that are not allowed in asset packs.", + "extraneous_files": extraneous_files, + "allowed_types": [".jpg", ".jpeg", ".png", ".avif", ".json"], + "total_extraneous_count": len(extraneous_files) + } + } + return { "status": "passed", "details": { - "message": f"{expected_file} found in {working_dir} and validated successfully.", + "message": f"{expected_file} found in {working_dir} and validated successfully. No extraneous files detected.", "extracted_dir": working_dir } } \ No newline at end of file diff --git a/profiles/ford_bnp.json b/profiles/ford_bnp.json index 569f8c3..99459d7 100755 --- a/profiles/ford_bnp.json +++ b/profiles/ford_bnp.json @@ -83,5 +83,19 @@ "working_dir": "__WORKING_DIR__", "linkingrecord_filename": "linkingrecord.json" } + }, + { + "script": "checks.beltline_validation_check", + "config": { + "working_dir": "__WORKING_DIR__", + "linkingrecord_filename": "linkingrecord.json" + } + }, + { + "script": "checks.extra_carousel_validation_check", + "config": { + "working_dir": "__WORKING_DIR__", + "linkingrecord_filename": "linkingrecord.json" + } } ] \ No newline at end of file