diff --git a/checks/check_series_permutations.py b/checks/check_series_permutations.py index 4f3de56..14339d6 100644 --- a/checks/check_series_permutations.py +++ b/checks/check_series_permutations.py @@ -1,17 +1,31 @@ import os +import re import json from typing import Dict, List, Set, Tuple, Any +def _is_ranger_model(linking_data: dict) -> bool: + """ + Detect if this is a Ranger (CV) model pack by checking header.ptvl. + Ranger packs use SE#/ABM codes instead of VS/ACM codes. + + :param linking_data: Parsed linkingrecord.json data + :return: True if this is a Ranger/CV model, False otherwise + """ + ptvl = linking_data.get('header', {}).get('ptvl', '') + return bool(re.match(r'^TR[AB]\d{2}$', str(ptvl), re.IGNORECASE)) + + def run_check(config: Dict[str, Any]) -> Dict[str, Any]: """ - Validate that series images exist for every unique VS/ACM combination found in exterior and showroom sections. + Validate that series images exist for every unique VS/ACM (or SE#/ABM for Ranger) combination + found in exterior and showroom sections. - This check validates that the "series" section contains an image for every unique combination of - VS- (Visual Selection) and ACM (Accessory Content Management) codes that appears in the - exterior and showroom sections. The validation creates a "source of truth" by extracting all - unique [ACM, VS] pairs from exterior and showroom records, then ensures the series section - provides an image for each combination. + For standard packs: validates VS- (Visual Selection) and ACM (Accessory Content Management) pairs. + For Ranger/CV packs (header.ptvl matches TR[AB]##): validates SE# and ABM code pairs instead. + + The validation creates a "source of truth" by extracting all unique code pairs from exterior + and showroom records, then ensures the series section provides an image for each combination. :param config: Configuration dictionary containing: - working_dir: Directory where linkingrecord.json and extracted files are located @@ -62,7 +76,34 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]: "error_message": "Invalid linkingrecord.json structure: 'items' missing or not a list." } - # STEP 1-3: Build "Source of Truth" - Extract unique [ACM, VS] pairs from exterior and showroom + # Detect model type to determine which code prefixes to use + is_ranger = _is_ranger_model(linkingrecord) + + if is_ranger: + # CV/Ranger model: uses SE# + ABM codes + primary_prefix = "se#" + secondary_prefix = "abm" + code_label = "SE#/ABM" + else: + # Standard model: uses VS + ACM codes + primary_prefix = "vs-" + secondary_prefix = "acm" + code_label = "VS/ACM" + + def _extract_code_pair(features): + """Extract the primary (VS/SE#) and secondary (ACM/ABM) code from a feature list.""" + primary_code = None + secondary_code = None + for feature in features: + if isinstance(feature, str): + fl = feature.lower() + if fl.startswith(primary_prefix): + primary_code = feature + elif fl.startswith(secondary_prefix): + secondary_code = feature + return primary_code, secondary_code + + # STEP 1-3: Build "Source of Truth" - Extract unique code pairs from exterior and showroom source_of_truth = set() for item in linkingrecord["items"]: @@ -98,33 +139,26 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(features, list): continue - # Extract the actual [ACM, VS] pair from this record's features + # Extract the code pair from this record's features # Each record represents ONE combination, not a cross-product - vs_code = None - acm_code = None + primary_code, secondary_code = _extract_code_pair(features) - for feature in features: - if isinstance(feature, str): - if feature.startswith("vs-"): - vs_code = feature - elif feature.startswith("acm"): - acm_code = feature - - # If this record has both ACM and VS, add the pair to source of truth - if vs_code and acm_code: - source_of_truth.add((acm_code, vs_code)) + # If this record has both codes, add the pair to source of truth + if primary_code and secondary_code: + source_of_truth.add((secondary_code, primary_code)) # Check if we found any valid combinations if not source_of_truth: return { "status": "passed", "details": { - "message": "No VS/ACM combinations found in exterior or showroom sections. Check may not be applicable to this pack type.", - "source_combinations_count": 0 + "message": f"No {code_label} combinations found in exterior or showroom sections. Check may not be applicable to this pack type.", + "source_combinations_count": 0, + "model_type": "Ranger/CV" if is_ranger else "Standard" } } - # STEP 4: Extract actual [ACM, VS] pairs from series section + # STEP 4: Extract actual code pairs from series section series_combinations = set() series_section_found = False @@ -154,30 +188,22 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(features, list): continue - # Extract the actual [ACM, VS] pair from this series record - # Each series record represents ONE combination, not a cross-product - vs_code = None - acm_code = None + # Extract the code pair from this series record + primary_code, secondary_code = _extract_code_pair(features) - for feature in features: - if isinstance(feature, str): - if feature.startswith("vs-"): - vs_code = feature - elif feature.startswith("acm"): - acm_code = feature - - # If this record has both ACM and VS, add the pair - if vs_code and acm_code: - series_combinations.add((acm_code, vs_code)) + # If this record has both codes, add the pair + if primary_code and secondary_code: + series_combinations.add((secondary_code, primary_code)) # Check if series section exists if not series_section_found: return { "status": "failed", "details": { - "message": "No series section found (viewtype='carousel', imagetype='series') in linkingrecord.", + "message": f"No series section found (viewtype='carousel', imagetype='series') in linkingrecord.", "source_combinations_count": len(source_of_truth), - "source_combinations": [f"({acm}, {vs})" for acm, vs in sorted(source_of_truth)] + "model_type": "Ranger/CV" if is_ranger else "Standard", + "source_combinations": [f"({sec}, {pri})" for sec, pri in sorted(source_of_truth)] } } @@ -188,17 +214,18 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]: # Generate detailed report if missing_combinations: error_details = { - "message": "Series validation failed: Some VS/ACM combinations from exterior/showroom are missing series images.", + "message": f"Series validation failed: Some {code_label} combinations from exterior/showroom are missing series images.", + "model_type": "Ranger/CV" if is_ranger else "Standard", "source_combinations_count": len(source_of_truth), "series_combinations_count": len(series_combinations), "missing_count": len(missing_combinations), - "missing_combinations": [f"({acm}, {vs})" for acm, vs in sorted(missing_combinations)] + "missing_combinations": [f"({sec}, {pri})" for sec, pri in sorted(missing_combinations)] } if extra_combinations: error_details["extra_count"] = len(extra_combinations) - error_details["extra_combinations"] = [f"({acm}, {vs})" for acm, vs in sorted(extra_combinations)] - error_details["extra_note"] = "Series section contains combinations not found in exterior/showroom." + error_details["extra_combinations"] = [f"({sec}, {pri})" for sec, pri in sorted(extra_combinations)] + error_details["extra_note"] = f"Series section contains {code_label} combinations not found in exterior/showroom." return { "status": "failed", @@ -207,14 +234,15 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]: # All required combinations are present in series success_details = { - "message": "All VS/ACM combinations from exterior and showroom have corresponding series images.", + "message": f"All {code_label} combinations from exterior and showroom have corresponding series images.", + "model_type": "Ranger/CV" if is_ranger else "Standard", "validated_combinations_count": len(source_of_truth), - "source_combinations": [f"({acm}, {vs})" for acm, vs in sorted(source_of_truth)] + "source_combinations": [f"({sec}, {pri})" for sec, pri in sorted(source_of_truth)] } if extra_combinations: success_details["note"] = f"{len(extra_combinations)} additional series combinations found that don't appear in exterior/showroom." - success_details["extra_combinations"] = [f"({acm}, {vs})" for acm, vs in sorted(extra_combinations)] + success_details["extra_combinations"] = [f"({sec}, {pri})" for sec, pri in sorted(extra_combinations)] return { "status": "passed", diff --git a/checks/exterior_interior_pairing_check.py b/checks/exterior_interior_pairing_check.py new file mode 100644 index 0000000..2a286bf --- /dev/null +++ b/checks/exterior_interior_pairing_check.py @@ -0,0 +1,221 @@ +import os +import json +from typing import Dict, Any, FrozenSet, Set, List + + +def run_check(config: Dict[str, Any]) -> Dict[str, Any]: + """ + Validate that every exterior base asset has a matching interior base asset with the same + feature set (excluding paint and trim codes), and vice versa. + + For each exterior item (viewtype="exterior", base imagetype/layer), the set of features + minus paint codes must match at least one interior item's feature set minus trim codes. + Both directions are checked: exterior items without an interior partner, and interior items + without an exterior partner. + + For MEC packs, items with experienceCondition="2d-background" are validated separately. + + :param config: Configuration dictionary containing: + - working_dir: Directory where linkingrecord.json is located + - linkingrecord_filename: The linking record filename (default: 'linkingrecord.json') + - paint_code_prefix: Prefix for paint codes to exclude from exterior features (default: 'pn') + - trim_code_prefix: Prefix for trim codes to exclude from interior features (default: 'tr-') + + :return: Dictionary with validation results: + - status: "passed", "failed", or "error" + - details: Breakdown of mismatches by direction and MEC condition + """ + working_dir = config.get("working_dir", "working") + linkingrecord_filename = config.get("linkingrecord_filename", "linkingrecord.json") + paint_prefix = config.get("paint_code_prefix", "pn").lower() + trim_prefix = config.get("trim_code_prefix", "tr-").lower() + linkingrecord_path = os.path.join(working_dir, linkingrecord_filename) + + if not os.path.exists(linkingrecord_path): + return { + "status": "error", + "error_message": f"Linking record file '{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"Malformed JSON in '{linkingrecord_filename}': {str(e)}" + } + except Exception as e: + return { + "status": "error", + "error_message": f"Error reading '{linkingrecord_filename}': {str(e)}" + } + + if not isinstance(linkingrecord, dict): + return { + "status": "error", + "error_message": "Invalid linkingrecord.json structure: root is not an object." + } + + 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." + } + + def _is_base_item(item: dict) -> bool: + """A base item has no imagetype (None) or imagetype='base', and layer=0 or no layer.""" + conditions = item.get("conditions", {}) + if not isinstance(conditions, dict): + return False + imagetype = conditions.get("imagetype") + layer = conditions.get("layer", 0) + return imagetype in (None, "base", "") and (layer == 0 or layer is None) + + def _strip_paint_codes(features: List[str]) -> FrozenSet[str]: + """Remove paint codes (pn prefix) from exterior feature list.""" + return frozenset(f for f in features if isinstance(f, str) and not f.lower().startswith(paint_prefix)) + + def _strip_trim_codes(features: List[str]) -> FrozenSet[str]: + """Remove trim codes (configurable prefix) from interior feature list.""" + return frozenset(f for f in features if isinstance(f, str) and not f.lower().startswith(trim_prefix)) + + def _collect_feature_sets(items_list, viewtype_val: str, strip_fn, mec_only: bool = False) -> Set[FrozenSet[str]]: + """Collect normalised feature sets for all base records of the given viewtype.""" + result = set() + for item in items_list: + if not isinstance(item, dict): + continue + conditions = item.get("conditions", {}) + if not isinstance(conditions, dict): + continue + if conditions.get("viewtype") != viewtype_val: + continue + if not _is_base_item(item): + continue + exp_condition = conditions.get("experienceCondition", "") + is_mec = exp_condition == "2d-background" + if mec_only and not is_mec: + continue + if not mec_only and is_mec: + continue + for record in item.get("records", []): + if not isinstance(record, dict): + continue + features = record.get("features", []) + if not isinstance(features, list): + continue + normalised = strip_fn(features) + if normalised: + result.add(normalised) + return result + + def _run_pairing_check(items_list, mec_only: bool = False) -> dict: + """Compare exterior and interior feature sets and return mismatch details.""" + ext_sets = _collect_feature_sets(items_list, "exterior", _strip_paint_codes, mec_only) + int_sets = _collect_feature_sets(items_list, "interior", _strip_trim_codes, mec_only) + + if not ext_sets and not int_sets: + return {"applicable": False} + + ext_only = ext_sets - int_sets + int_only = int_sets - ext_sets + + return { + "applicable": True, + "exterior_count": len(ext_sets), + "interior_count": len(int_sets), + "exterior_without_interior": len(ext_only), + "interior_without_exterior": len(int_only), + "ext_only_feature_sets": [sorted(list(s)) for s in sorted(ext_only, key=lambda x: sorted(x))], + "int_only_feature_sets": [sorted(list(s)) for s in sorted(int_only, key=lambda x: sorted(x))], + } + + items = linkingrecord["items"] + + # Standard pairing check (excluding 2d-background MEC items) + standard = _run_pairing_check(items, mec_only=False) + + # MEC 2d-background pairing check + mec = _run_pairing_check(items, mec_only=True) + + # Determine if any items had the MEC condition + has_mec_items = mec.get("applicable", False) + + # Evaluate overall pass/fail + standard_pass = not standard.get("applicable") or ( + standard.get("exterior_without_interior", 0) == 0 and + standard.get("interior_without_exterior", 0) == 0 + ) + mec_pass = not mec.get("applicable") or ( + mec.get("exterior_without_interior", 0) == 0 and + mec.get("interior_without_exterior", 0) == 0 + ) + + if not standard.get("applicable") and not has_mec_items: + return { + "status": "passed", + "details": { + "message": "No exterior or interior base items found. Check not applicable to this pack.", + "applicable": False + } + } + + overall_pass = standard_pass and mec_pass + status = "passed" if overall_pass else "failed" + + details: Dict[str, Any] = {} + + if standard.get("applicable"): + details["standard"] = { + "exterior_base_sets": standard["exterior_count"], + "interior_base_sets": standard["interior_count"], + "exterior_without_interior_partner": standard["exterior_without_interior"], + "interior_without_exterior_partner": standard["interior_without_exterior"], + } + if standard["exterior_without_interior"] > 0: + details["standard"]["exterior_only_feature_sets"] = standard["ext_only_feature_sets"] + if standard["interior_without_exterior"] > 0: + details["standard"]["interior_only_feature_sets"] = standard["int_only_feature_sets"] + + if has_mec_items: + details["mec_2d_background"] = { + "exterior_base_sets": mec["exterior_count"], + "interior_base_sets": mec["interior_count"], + "exterior_without_interior_partner": mec["exterior_without_interior"], + "interior_without_exterior_partner": mec["interior_without_exterior"], + } + if mec["exterior_without_interior"] > 0: + details["mec_2d_background"]["exterior_only_feature_sets"] = mec["ext_only_feature_sets"] + if mec["interior_without_exterior"] > 0: + details["mec_2d_background"]["interior_only_feature_sets"] = mec["int_only_feature_sets"] + + if overall_pass: + msg_parts = [] + if standard.get("applicable"): + msg_parts.append(f"all {standard['exterior_count']} exterior/interior base pairs matched") + if has_mec_items: + msg_parts.append(f"all {mec['exterior_count']} MEC 2d-background pairs matched") + details["message"] = "Exterior/interior pairing passed: " + "; ".join(msg_parts) + "." + else: + msg_parts = [] + if standard.get("applicable") and not standard_pass: + ext_miss = standard["exterior_without_interior"] + int_miss = standard["interior_without_exterior"] + if ext_miss: + msg_parts.append(f"{ext_miss} exterior base set(s) with no interior partner") + if int_miss: + msg_parts.append(f"{int_miss} interior base set(s) with no exterior partner") + if has_mec_items and not mec_pass: + ext_miss = mec["exterior_without_interior"] + int_miss = mec["interior_without_exterior"] + if ext_miss: + msg_parts.append(f"{ext_miss} MEC exterior base set(s) with no interior partner") + if int_miss: + msg_parts.append(f"{int_miss} MEC interior base set(s) with no exterior partner") + details["message"] = "Exterior/interior pairing failed: " + "; ".join(msg_parts) + "." + + details["paint_code_prefix"] = paint_prefix + details["trim_code_prefix"] = trim_prefix + + return {"status": status, "details": details} diff --git a/checks/html_reporter.py b/checks/html_reporter.py index 2ada65b..8e68540 100755 --- a/checks/html_reporter.py +++ b/checks/html_reporter.py @@ -172,7 +172,9 @@ class HTMLReporter: "image_linking_check": "Image Link References Check", "business_data_check": "Business Data Validation", "image_format_check": "Image Format Validation", - "layer_depth_check": "Layer Depth Validation" + "layer_depth_check": "Layer Depth Validation", + "check_series_permutations": "Series Permutations Validation", + "exterior_interior_pairing_check": "Exterior / Interior Base Asset Pairing" } # Return the friendly name or format the module name if not in the mapping @@ -326,6 +328,8 @@ class HTMLReporter: elif 'record_results' in details and 'overall_coverage' in details: # Extra carousel validation check formatted_parts.append(HTMLReporter._format_extra_carousel_validation(details)) + elif check_type == "exterior_interior_pairing_check" or 'standard' in details or 'mec_2d_background' in details: + formatted_parts.append(HTMLReporter._format_exterior_interior_pairing(details)) # If no special formatting applied so far, use generic formatting elif not formatted_parts: formatted_parts.append(f''' @@ -1576,6 +1580,107 @@ class HTMLReporter: ''' + @staticmethod + def _format_exterior_interior_pairing(details: dict) -> str: + """Format exterior/interior base asset pairing check results.""" + try: + def _feature_set_table(feature_sets: list, label: str, badge_class: str) -> str: + if not feature_sets: + return "" + rows = "".join( + f"{i+1}{', '.join(fs)}" + for i, fs in enumerate(feature_sets) + ) + return f''' +
+
{label} ({len(feature_sets)})
+ + + + + {rows} +
#Feature Set (normalised)
+
+ ''' + + def _render_section(section_data: dict, title: str) -> str: + if not section_data: + return "" + ext_count = section_data.get("exterior_base_sets", 0) + int_count = section_data.get("interior_base_sets", 0) + ext_miss = section_data.get("exterior_without_interior_partner", 0) + int_miss = section_data.get("interior_without_exterior_partner", 0) + passed = ext_miss == 0 and int_miss == 0 + header_class = "table-success" if passed else "table-danger" + + summary = f''' +
+
{title}
+ + + + + + + + + + + + +
MetricCount
Exterior base sets{ext_count}
Interior base sets{int_count}
Exterior without interior partner{ext_miss}
Interior without exterior partner{int_miss}
+ ''' + + tables = "" + if "exterior_only_feature_sets" in section_data: + tables += _feature_set_table( + section_data["exterior_only_feature_sets"], + "Exterior Sets Without an Interior Partner", + "table-danger" + ) + if "interior_only_feature_sets" in section_data: + tables += _feature_set_table( + section_data["interior_only_feature_sets"], + "Interior Sets Without an Exterior Partner", + "table-warning" + ) + + return summary + tables + "
" + + parts = [] + + standard = details.get("standard") + if standard: + parts.append(_render_section(standard, "Standard Base Items")) + + mec = details.get("mec_2d_background") + if mec: + parts.append(_render_section(mec, "MEC 2D-Background Items")) + + if not parts: + return '
No pairing data available.
' + + paint_prefix = details.get("paint_code_prefix", "pn") + trim_prefix = details.get("trim_code_prefix", "tr-") + parts.append(f''' +
+ Paint codes excluded from exterior features: prefix {paint_prefix}  |  + Trim codes excluded from interior features: prefix {trim_prefix} +
+ ''') + + return "".join(parts) + + except Exception as e: + logging.warning(f"Error formatting exterior/interior pairing: {e}") + return f''' +
+ Error formatting exterior/interior pairing: {str(e)} +
+
{json.dumps(details, indent=2, default=str)}
+ ''' + + if __name__ == "__main__": import sys if len(sys.argv) != 3: diff --git a/checks/unzip_and_verify_check.py b/checks/unzip_and_verify_check.py index c58fab6..99da929 100755 --- a/checks/unzip_and_verify_check.py +++ b/checks/unzip_and_verify_check.py @@ -107,6 +107,15 @@ def validate_linkingrecord_header(linkingrecord_data): "error": "Must be a string value" }) + # Validate fullLoad (must always be "Y") + full_load = header.get("fullLoad", "") + if full_load != "Y": + errors.append({ + "field": "fullLoad", + "value": full_load, + "error": "Must be exactly 'Y' (full load packs only)" + }) + return len(errors) == 0, errors def validate_linking_record_filetypes(linkingrecord_data): diff --git a/profiles/ford_bnp.json b/profiles/ford_bnp.json index 99459d7..189ba61 100755 --- a/profiles/ford_bnp.json +++ b/profiles/ford_bnp.json @@ -97,5 +97,14 @@ "working_dir": "__WORKING_DIR__", "linkingrecord_filename": "linkingrecord.json" } + }, + { + "script": "checks.exterior_interior_pairing_check", + "config": { + "working_dir": "__WORKING_DIR__", + "linkingrecord_filename": "linkingrecord.json", + "paint_code_prefix": "pn", + "trim_code_prefix": "tr-" + } } ] \ No newline at end of file