diff --git a/scripts/generate_enhancements_ini.py b/scripts/generate_enhancements_ini.py index 9fd006e..afffa3e 100644 --- a/scripts/generate_enhancements_ini.py +++ b/scripts/generate_enhancements_ini.py @@ -320,6 +320,8 @@ def _index_rglob(xml_path_index: dict, entity_dir: Path, records_dir: Path) -> l def append_enhancements(existing_value: str, enhancements_block: str, separator: str = ENHANCEMENT_SEPARATOR) -> str: + if existing_value is None: + existing_value = "" if not enhancements_block: return existing_value # Strip any existing stats/mission details block. BP/ITEMS/BLUEPRINT DATA @@ -448,6 +450,61 @@ def _loc_name_key(root: ET.Element) -> str | None: return None +def _synthesize_description(root: ET.Element, xml_file: Path, key: str) -> str: + """Build a synthetic description from XML attributes when base.ini has no entry. + + Returns the richest available description so discovered items still appear + in the table with useful context. + """ + parts: list[str] = [] + + # 1. Item name: prefer the XML Name loc ref, fall back to filename + name = _loc_name_key(root) + if name: + parts.append(name) + else: + stem = xml_file.stem.replace("_", " ").strip() + if stem: + parts.append(stem) + + # 2. Ship-specific attributes from VehicleComponentParams + vpc = _find(root, "VehicleComponentParams") + if vpc is not None: + attrs: list[str] = [] + career = vpc.get("vehicleCareer", "") + if career.startswith("@"): + attrs.append(f"Class: {career.lstrip('@')}") + role = vpc.get("vehicleRole", "") + if role.startswith("@"): + attrs.append(f"Role: {role.lstrip('@')}") + crew = vpc.get("crewSize", "") + if crew: + attrs.append(f"Crew: {crew}") + bbox = _find(root, "maxBoundingBoxSize") + if bbox is not None: + length = bbox.get("y", "") + if length: + attrs.append(f"Length: {length}m") + if attrs: + parts.append(" | ".join(attrs)) + + # 3. Component attributes (ItemComponentParams) + icp = _find(root, "ItemComponentParams") + if icp is not None: + item_type = icp.get("itemType", "") + if item_type: + parts.append(f"Item Type: {item_type}") + + # 4. Missile tracking signal type + tp = _find(root, "targetingParams") + if tp is not None: + sig = tp.get("trackingSignalType", "") + if sig: + parts.append(f"Tracking: {sig}") + + return "\n".join(parts) if parts else key.replace("_", " ") + + # ── Tag emitters ───────────────────────────────────────────────────────────── # Each emitter pulls structured data off the description text and/or XML and # hands it to render_tag(). Format strings, ordering, separators, and the @@ -483,6 +540,14 @@ def _loc_name_key(root: ET.Element) -> str | None: # Salvage / repair "Salvage Head": "SAL", + # Ship components + "Shield Generator": "SHLD", + "Cooler": "COOL", + "Power Plant": "POWR", + "Quantum Drive": "QDRV", + "Radar": "RADR", + "Bomb Rack": "BRK", + # Ship weapons — energy damage "Laser Beam": "E", "Laser Cannon": "E", @@ -559,31 +624,83 @@ def _component_name_tag(desc_value: str, root: ET.Element | None = None, # (Size: 0, Size: 1). The capture is the digits only; "S0" → "0", # "S00" → "00", "2" → "2", so the tag normalises to f"S{captured}". size_m = re.search(r"Size:\s*S?(\d+)", desc_value) - if not size_m: + if size_m: + size = size_m.group(1) + elif root is not None: + # Fall back to XML: extract size from entity class name in root tag + root_tag = root.tag.split(".")[-1] if "." in root.tag else root.tag + xml_size = _extract_item_size(root_tag) + if not xml_size: + return None + size = xml_size.lstrip("S") + else: return None - size = size_m.group(1) grade_m = re.search(r"Grade:\s*([A-D])", desc_value) class_m = re.search(r"Class:\s*(\w+)", desc_value) + # AttachDef fallback (bomb racks, other ordnance containers): these + # entities have no ItemComponentParams and their Grade is numeric (1→A). + attach_def = None + attach_grade_letter = None + attach_class_name = None + if root is not None: + attach_el = _find(root, "SAttachableComponentParams") + if attach_el is not None: + attach_def = attach_el.find("AttachDef") + + if not grade_m and attach_def is not None: + num_grade = attach_def.get("Grade", "") + if num_grade.isdigit(): + g_idx = int(num_grade) - 1 + if 0 <= g_idx <= 3: + attach_grade_letter = "ABCD"[g_idx] + + if not class_m and attach_def is not None: + subtype = attach_def.get("SubType", "") + if subtype: + # CamelCase → space-separated (e.g. "BombRack" → "Bomb Rack") + attach_class_name = re.sub(r"([a-z])([A-Z])", r"\1 \2", subtype) + + # Resolve effective grade and class for downstream paths + grade_letter = (grade_m.group(1) if grade_m else None) or attach_grade_letter + class_name = (class_m.group(1) if class_m else None) or attach_class_name + + # When Class: is missing from text, try to derive from XML ItemComponentParams + xml_item_type = None + if not class_name and root is not None: + icp = _find(root, "ItemComponentParams") + if icp is not None: + raw_type = icp.get("itemType", "") + # Map CamelCase XML itemType to the space-separated form + # used in _ITEM_TYPE_ABBREV (e.g. "ShieldGenerator" → "Shield Generator") + _ITEM_TYPE_XML_MAP = { + "ShieldGenerator": "Shield Generator", + "Cooler": "Cooler", + "PowerPlant": "Power Plant", + "QuantumDrive": "Quantum Drive", + "Radar": "Radar", + } + xml_item_type = _ITEM_TYPE_XML_MAP.get(raw_type, raw_type) + # Strict path: full ship-component trio with a recognised class → # render via the Tag Builder pipeline so user customisation applies. - if grade_m and class_m and class_m.group(1) in DEFAULT_COMPONENT_CLASS_MAPPING: + if grade_letter and class_name and class_name in DEFAULT_COMPONENT_CLASS_MAPPING: cfg = config or DEFAULT_TAG_CONFIGS.get("components") if cfg is not None and render_tag is not None: out = render_tag(cfg, { - "class": class_m.group(1), + "class": class_name, "size": size, - "grade": grade_m.group(1), + "grade": grade_letter, }) if out: return out # Defensive fallback when tag_builder isn't importable (e.g. tests # in environments without src/ on the path). Preserves the legacy # hardcoded output shape. - abbrev_tuple = DEFAULT_COMPONENT_CLASS_MAPPING.get(class_m.group(1)) + abbrev_tuple = DEFAULT_COMPONENT_CLASS_MAPPING.get(class_name) if abbrev_tuple: - return f"[{abbrev_tuple[1]}-S{size}-{grade_m.group(1)}]" + return f"[{abbrev_tuple[1]}-S{size}-{grade_letter}]" # Fallback path: classify by Item Type when Class: is missing or # unrecognised. The character class excludes backslash so the capture @@ -594,16 +711,22 @@ def _component_name_tag(desc_value: str, root: ET.Element | None = None, type_m = re.search(r"Item Type:\s*([^\\\n]+?)\s*(?:\\n|\n|$)", desc_value) if type_m: type_abbrev = _ITEM_TYPE_ABBREV.get(type_m.group(1).strip()) - - if not (type_abbrev or grade_m): + # Fall back to XML-derived item type when text has no Item Type: line + if not type_abbrev and xml_item_type: + type_abbrev = _ITEM_TYPE_ABBREV.get(xml_item_type) + # Fall back to AttachDef SubType (bomb racks, etc.) + if not type_abbrev and class_name: + type_abbrev = _ITEM_TYPE_ABBREV.get(class_name) + + if not (type_abbrev or grade_letter): return None parts: list[str] = [] if type_abbrev: parts.append(type_abbrev) parts.append(f"S{size}") - if grade_m: - parts.append(grade_m.group(1)) + if grade_letter: + parts.append(grade_letter) return f"[{'-'.join(parts)}]" @@ -1300,6 +1423,41 @@ def _fmt_range_m(v: float) -> str: return "\\n".join(lines) if lines else "" +def enhancements_bomb_rack(root: ET.Element) -> str: + """Extract bomb-rack enhancements: size, grade, slot count, health.""" + # Bomb racks nest their Localization inside SAttachableComponentParams/AttachDef + attach = _find(root, "SAttachableComponentParams") + if attach is None: + return "" + ad = attach.find("AttachDef") + if ad is None: + return "" + + size = ad.get("Size", "") + grade = ad.get("Grade", "") + + # Count bomb slots from SCItemMissileRackParams/slotTags + rack = _find(root, "SCItemMissileRackParams") + slot_count = 0 + if rack is not None: + slot_tags = rack.find("slotTags") + if slot_tags is not None: + slot_count = len(list(slot_tags.findall("String"))) + + comp_hp = _attr(root, "SHealthComponentParams", "Health") + + lines = [] + if size: + lines.append(f"Size: S{size}") + if grade: + lines.append(f"Grade: {grade}") + if slot_count > 0: + lines.append(f"Bomb Slots: {slot_count}") + if comp_hp: + lines.append(f"Component HP: {_fmt(comp_hp)}") + return "\\n".join(lines) if lines else "" + + def enhancements_radar(root: ET.Element) -> str: """Extract radar/sensor stats. @@ -3779,7 +3937,7 @@ def scan_spaceships( ) -> dict[str, str]: """Scan DataForge spaceship entities and generate ship stat descriptions.""" out: dict[str, str] = {} - matched = missed = skipped = 0 + matched = missed = skipped = discovered = 0 if xml_path_index is not None and records_dir is not None: key = spaceships_dir.relative_to(records_dir).as_posix() @@ -3810,9 +3968,10 @@ def scan_spaceships( loc_key = desc_attr.lstrip("@") base_value = loc.get(loc_key) - if base_value is None: - missed += 1 - continue + is_discovered = base_value is None + if is_discovered: + base_value = _synthesize_description(root, xml_file, loc_key) + discovered += 1 # Match ship class to flight controller root_tag = root.tag @@ -3830,10 +3989,14 @@ def scan_spaceships( if loc_key not in out: out[loc_key] = append_enhancements(base_value, block) matched += 1 + elif is_discovered: + if loc_key not in out: + out[loc_key] = base_value + matched += 1 else: missed += 1 - logger.info(f"Spaceships: {matched} matched, {missed} no enhancements/key, {skipped} skipped (AI/templates)") + logger.info(f"Spaceships: {matched} matched, {discovered} discovered, {missed} no enhancements/key, {skipped} skipped (AI/templates)") return out @@ -4005,7 +4168,9 @@ def scan_entity_dir( ) -> dict[str, str]: """ Scan all XML files in entity_dir, extract localization key + enhancements, - and return {loc_key: augmented_value} for keys found in `loc`. + and return {loc_key: augmented_value}. For keys missing from `loc`, a + synthetic description is built from XML attributes so discovered items + still appear in the output. ammo_lookup is passed to enhancement_fn only when it accepts it (weapons). loc is the base.ini localization dict for value lookup. @@ -4018,7 +4183,7 @@ def scan_entity_dir( loc_key_fn = _loc_key out: dict[str, str] = {} - matched = missed = skipped = 0 + matched = missed = skipped = discovered = 0 xml_files = ( _index_rglob(xml_path_index, entity_dir, records_dir) @@ -4037,9 +4202,10 @@ def scan_entity_dir( continue base_value = (loc or {}).get(key) - if base_value is None: - missed += 1 - continue + is_discovered = base_value is None + if is_discovered: + base_value = _synthesize_description(root, xml_file, key) + discovered += 1 try: if ammo_lookup is not None: @@ -4053,8 +4219,8 @@ def scan_entity_dir( if enhancements_block: out[key] = append_enhancements(base_value, enhancements_block, separator) matched += 1 - elif capture_all: - # Still emit the base value so all missions are captured + elif capture_all or is_discovered: + # Still emit the base value so all missions / discovered items are captured if key not in out: out[key] = base_value matched += 1 @@ -4071,6 +4237,10 @@ def scan_entity_dir( name_key = _loc_name_key(root) if name_key: name_value = loc.get(name_key) + is_discovered_name = name_value is None + if is_discovered_name: + # Synthesize name from XML file stem + name_value = xml_file.stem.replace("_", " ").strip() if name_value: tagger = name_tag_fn or _component_name_tag tag = tagger(base_value, root) @@ -4091,7 +4261,7 @@ def scan_entity_dir( else: out[short_key] = f"{tag} {short_value}" - logger.info(f"{entity_dir.name}: {matched} matched, {missed} no enhancements, {skipped} no loc key") + logger.info(f"{entity_dir.name}: {matched} matched, {discovered} discovered, {missed} no enhancements, {skipped} no loc key") return out @@ -4122,6 +4292,7 @@ def _comp_tagger(desc_value: str, root: ET.Element | None = None) -> str | None: ("cooler", enhancements_cooler), ("powerplant", enhancements_powerplant), ("quantumdrive", enhancements_quantum_drive), + ("bombcompartments", enhancements_bomb_rack), ]: out.update(scan_entity_dir(ships_scitem / subdir, fn, loc=loc, generate_name_tags=True, name_tag_fn=_comp_tagger, @@ -4462,8 +4633,9 @@ def _run_gen_missions(ctx: dict) -> dict[str, str]: mission_titles_augmented = 0 for title_key, variants in contractgen_missions.items(): base_title = (loc or {}).get(title_key) - if not base_title: - continue + is_discovered_title = not base_title + if is_discovered_title: + base_title = title_key.replace("_", " ").strip() seen_tiers: list[tuple[int, int]] = [] # 10-tuple destructure: (system_name, success_xp, failure_xp, desc_key, @@ -4517,12 +4689,21 @@ def _run_gen_missions(ctx: dict) -> dict[str, str]: unique_desc_keys: list[str] = [] for v in variants: dk = v[3] - if dk and dk in loc and dk not in unique_desc_keys: + if dk and dk not in unique_desc_keys: unique_desc_keys.append(dk) for desc_key in unique_desc_keys: desc_variants = [v for v in variants if v[3] == desc_key] - base_desc = loc[desc_key] + base_desc = loc.get(desc_key) + if base_desc is None: + # Synthesize from contract debug name in variant tuple + contract_debug = next( + (v[9] for v in desc_variants if v[9]), "" + ) + if contract_debug: + base_desc = contract_debug.replace("_", " ").strip() + else: + base_desc = desc_key.replace("_", " ").strip() all_flags: list[str] = [] agg_spawns = _empty_spawn_breakdown() all_difficulties: list[str] = [] @@ -4708,8 +4889,6 @@ def _run_gen_missions(ctx: dict) -> dict[str, str]: if _is_sentinel_loc_ref(title_attr) or _is_sentinel_loc_ref(desc_attr): continue title_key = title_attr.lstrip("@") - if not (loc or {}).get(title_key): - continue xp = _extract_mission_xp(root, reputation_lookup) if xp > 0: pu_title_xps.setdefault(title_key, []).append(xp) @@ -4720,7 +4899,7 @@ def _run_gen_missions(ctx: dict) -> dict[str, str]: for title_key, xps in pu_title_xps.items(): base_title = (loc or {}).get(title_key) if not base_title: - continue + base_title = title_key.replace("_", " ").strip() current = out.get(title_key, base_title) if xp_tag_re.search(current): continue diff --git a/src/gui/config_tab.py b/src/gui/config_tab.py index 449ed7c..a3cc1dd 100644 --- a/src/gui/config_tab.py +++ b/src/gui/config_tab.py @@ -7,6 +7,7 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLineEdit, QPushButton, QLabel, QFileDialog, QMessageBox, QComboBox, + QCheckBox, ) from PyQt6.QtCore import pyqtSignal, QTimer @@ -88,6 +89,7 @@ def setup_ui(self): self.theme_combo.setMaximumWidth(150) appearance_layout.addWidget(self.theme_combo) appearance_layout.addStretch() + layout.addWidget(appearance_group) # ── Star Citizen Installation ──────────────────────────────────────── @@ -290,6 +292,15 @@ def setup_ui(self): tools_desc.setWordWrap(True) tools_layout.addWidget(tools_desc) + self.include_new_cb = QCheckBox("Include new lines") + self.include_new_cb.setToolTip( + "When checked, items discovered from DataForge XML (status 'New') " + "that have non-empty text will be included in the applied global.ini." + ) + self.include_new_cb.setChecked(AppSettings.get_include_new_lines()) + self.include_new_cb.toggled.connect(self._on_include_new_toggled) + tools_layout.addWidget(self.include_new_cb) + button_layout = QHBoxLayout() import_btn = QPushButton("Import INI...") @@ -366,6 +377,9 @@ def _apply_theme_change(self, theme: str): if hasattr(mw, "refresh_action_buttons"): mw.refresh_action_buttons() + def _on_include_new_toggled(self, checked: bool): + AppSettings.set_include_new_lines(checked) + # ── Game path ──────────────────────────────────────────────────────────── def _save_game_path(self): diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 9e2ee28..7aaedfa 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -1292,6 +1292,20 @@ def apply_to_game(self): if entry.custom_value } + # When "Include new lines" is off, strip discovered items + # (status "New" with no user override) from the enhancements + # source so they don't flow into the applied global.ini. + if not AppSettings.get_include_new_lines(): + new_keys = { + entry.key for entry in self.entries + if entry.status == "New" and not entry.custom_value + } + if new_keys and "enhancements" in sources_dict: + sources_dict["enhancements"] = { + k: v for k, v in sources_dict["enhancements"].items() + if k not in new_keys + } + # Merge all sources in hierarchy order, with user edits on top merged_dict = merge_sources_by_hierarchy(sources_dict, hierarchy, user_overrides_dict) diff --git a/src/gui/string_table_model.py b/src/gui/string_table_model.py index ce5eb5c..b37c50b 100644 --- a/src/gui/string_table_model.py +++ b/src/gui/string_table_model.py @@ -36,7 +36,7 @@ "Modified": QColor("#4CAF50"), # green — user-customized "Enhanced": QColor("#2196F3"), # blue — Smart Citizen enhancement pipeline "Unmodified": QColor("#999999"), # grey — stock value, unchanged - "New": QColor("#FF9800"), # orange — exists only in user/enhancements + "New": QColor("#FF9800"), # orange — discovered from XML, not in base.ini } _DEFAULT_STATUS_COLOR = QColor("black") diff --git a/src/parser/ini_parser.py b/src/parser/ini_parser.py index b6ab066..47064e8 100644 --- a/src/parser/ini_parser.py +++ b/src/parser/ini_parser.py @@ -190,9 +190,13 @@ def load_source_files( # User has an override for this key status = 'Modified' else: - # No user override — use source-origin-based status source = source_origin.get(key, base_source) - status = _determine_status_from_source(source, base_source) + # Discovered from DataForge XML — key only exists in enhancements, + # not in the global (base.ini) source. + if source == 'enhancements' and key not in base_sources.get(base_source, {}): + status = 'New' + else: + status = _determine_status_from_source(source, base_source) source = source_origin.get(key, 'user' if key not in base_merged else base_source) diff --git a/src/utils/settings.py b/src/utils/settings.py index c987977..6b3fd32 100644 --- a/src/utils/settings.py +++ b/src/utils/settings.py @@ -28,6 +28,7 @@ class AppSettings: # Settings keys - Enhancements ENHANCEMENTS_ENABLED = "enhancements_enabled" + INCLUDE_NEW_LINES = "include_new_lines" # Settings keys - Tutorial # Stores the app version string ("0.9.3") that last marked the guided tour @@ -182,6 +183,16 @@ def set_enhancements_enabled(enabled: bool) -> None: """Enable or disable enhancements.""" AppSettings.settings().setValue(AppSettings.ENHANCEMENTS_ENABLED, enabled) + @staticmethod + def get_include_new_lines() -> bool: + """Check whether discovered items (status 'New') are included in apply output.""" + return AppSettings.settings().value(AppSettings.INCLUDE_NEW_LINES, False, type=bool) + + @staticmethod + def set_include_new_lines(enabled: bool) -> None: + """Include or exclude discovered items from apply output.""" + AppSettings.settings().setValue(AppSettings.INCLUDE_NEW_LINES, enabled) + @staticmethod def get_enhancement_category_enabled(key: str) -> bool: """Check if a specific enhancement category is enabled (default: True).""" diff --git a/tests/test_discovered_items.py b/tests/test_discovered_items.py new file mode 100644 index 0000000..ecdf527 --- /dev/null +++ b/tests/test_discovered_items.py @@ -0,0 +1,296 @@ +"""Tests for XML-based item discovery when base.ini has no entry. + +The enhancement generator now synthesizes descriptions from XML attributes +for items whose loc key is absent from base.ini, so they appear in the +output with status "New". +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest +from lxml import etree as ET + +pytestmark = pytest.mark.unit + + +@pytest.fixture(scope="module") +def gen_module(): + repo_root = Path(__file__).resolve().parent.parent + script_path = repo_root / "scripts" / "generate_enhancements_ini.py" + spec = importlib.util.spec_from_file_location("generate_enhancements_ini_discovery_test", script_path) + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod + + +# ── _synthesize_description ────────────────────────────────────────────────── + +class TestSynthesizeDescription: + """Covers ``_synthesize_description`` with various XML shapes.""" + + def test_from_loc_name_key(self, gen_module, tmp_path): + """When the XML has a Name loc ref, use it as the display name.""" + xml = """ + + + + """ + xml_file = tmp_path / "test_shield.xml" + xml_file.write_text(xml, encoding="utf-8") + root = ET.parse(xml_file).getroot() + result = gen_module._synthesize_description(root, xml_file, "item_DescSHLD_TestShield") + assert "item_NameSHLD_TestShield" in result + + def test_from_file_stem(self, gen_module, tmp_path): + """When no Name loc ref, fall back to cleaned file stem.""" + xml = """ + + + + """ + xml_file = tmp_path / "AEGS_Test_Shield.xml" + xml_file.write_text(xml, encoding="utf-8") + root = ET.parse(xml_file).getroot() + result = gen_module._synthesize_description(root, xml_file, "item_DescSHLD_TestShield") + assert "AEGS Test Shield" in result + + def test_from_key_fallback(self, gen_module, tmp_path): + """When no Name ref and no Localization element, fall back to file stem.""" + xml = """""" + xml_file = tmp_path / "unnamed_entity.xml" + xml_file.write_text(xml, encoding="utf-8") + root = ET.parse(xml_file).getroot() + result = gen_module._synthesize_description(root, xml_file, "item_DescSHLD_TestShield") + assert "unnamed entity" in result + + def test_vehicle_params_extracted(self, gen_module, tmp_path): + """Ship XMLs get career, role, crew, length extracted.""" + xml = """ + + + + + + + """ + xml_file = tmp_path / "AEGS_Test_Ship.xml" + xml_file.write_text(xml, encoding="utf-8") + root = ET.parse(xml_file).getroot() + result = gen_module._synthesize_description(root, xml_file, "vehicle_DescAEGS_TestShip") + assert "Crew: 2" in result + assert "32.5m" in result + assert "vehicle_career_Fighter" in result + assert "vehicle_role_HeavyFighter" in result + + def test_missile_tracking_extracted(self, gen_module, tmp_path): + """Missile XMLs get tracking signal type extracted.""" + xml = """ + + + + + """ + xml_file = tmp_path / "test_missile.xml" + xml_file.write_text(xml, encoding="utf-8") + root = ET.parse(xml_file).getroot() + result = gen_module._synthesize_description(root, xml_file, "item_DescMISS_TestMissile") + assert "Tracking: Infrared" in result + + def test_item_type_extracted(self, gen_module, tmp_path): + """Component XMLs get item type extracted.""" + xml = """ + + + + + """ + xml_file = tmp_path / "test_comp.xml" + xml_file.write_text(xml, encoding="utf-8") + root = ET.parse(xml_file).getroot() + result = gen_module._synthesize_description(root, xml_file, "item_DescSHLD_TestComp") + assert "Item Type: ShieldGenerator" in result + + +# ── scan_entity_dir discovery ──────────────────────────────────────────────── + +class TestScanEntityDirDiscovery: + """Covers ``scan_entity_dir`` discovering items missing from loc.""" + + def _make_component_xml(self, tmp_path: Path, name: str, desc_key: str, + health: int = 500) -> Path: + xml = f""" + + + + + + """ + xml_file = tmp_path / f"{name}.xml" + xml_file.write_text(xml, encoding="utf-8") + return xml_file + + def test_discovers_item_not_in_loc(self, gen_module, tmp_path): + """Items with a valid loc key but missing from loc dict are discovered.""" + self._make_component_xml(tmp_path, "test_shield", "item_DescSHLD_Missing") + + def dummy_enhancement_fn(root): + return "HP: 500" + + result = gen_module.scan_entity_dir( + tmp_path, dummy_enhancement_fn, loc={}, capture_all=False, + ) + assert "item_DescSHLD_Missing" in result + assert "HP: 500" in result["item_DescSHLD_Missing"] + + def test_enhances_existing_item_normally(self, gen_module, tmp_path): + """Items present in loc still get the normal base+stats treatment.""" + self._make_component_xml(tmp_path, "test_shield", "item_DescSHLD_Existing") + loc = {"item_DescSHLD_Existing": "A basic shield generator."} + + def dummy_enhancement_fn(root): + return "HP: 500" + + result = gen_module.scan_entity_dir( + tmp_path, dummy_enhancement_fn, loc=loc, capture_all=False, + ) + assert "item_DescSHLD_Existing" in result + assert "A basic shield generator." in result["item_DescSHLD_Existing"] + assert "HP: 500" in result["item_DescSHLD_Existing"] + + def test_discovered_item_with_empty_enhancement(self, gen_module, tmp_path): + """Discovered items are emitted even when enhancement_fn returns empty.""" + self._make_component_xml(tmp_path, "test_empty", "item_DescSHLD_Empty") + + def empty_enhancement_fn(root): + return "" + + result = gen_module.scan_entity_dir( + tmp_path, empty_enhancement_fn, loc={}, capture_all=False, + ) + assert "item_DescSHLD_Empty" in result + + def test_discovered_counter_increments(self, gen_module, tmp_path): + """The discovered counter tracks items found via XML but missing from loc.""" + self._make_component_xml(tmp_path, "shield1", "item_DescSHLD_A") + self._make_component_xml(tmp_path, "shield2", "item_DescSHLD_B") + loc = {"item_DescSHLD_B": "Existing shield."} + + def dummy_enhancement_fn(root): + return "stats" + + gen_module.scan_entity_dir( + tmp_path, dummy_enhancement_fn, loc=loc, capture_all=False, + ) + # No direct way to check the counter (it's local), but the function + # should complete without error and produce both entries. + + +# ── scan_spaceships discovery ──────────────────────────────────────────────── + +class TestScanSpaceshipsDiscovery: + """Covers ``scan_spaceships`` discovering ships missing from loc.""" + + def _make_ship_xml(self, tmp_path: Path, name: str, desc_key: str, + crew: int = 1) -> Path: + xml = f""" + + + + + + + """ + xml_file = tmp_path / f"{name}.xml" + xml_file.write_text(xml, encoding="utf-8") + return xml_file + + def test_discovers_ship_not_in_loc(self, gen_module, tmp_path): + """Ships with a valid loc key but missing from loc dict are discovered.""" + self._make_ship_xml(tmp_path, "AEGS_Test_Ship", "vehicle_DescAEGS_TestShip") + + result = gen_module.scan_spaceships( + tmp_path, controller_lookup={}, loc={}, + ) + assert "vehicle_DescAEGS_TestShip" in result + entry = result["vehicle_DescAEGS_TestShip"] + assert "Crew: 1" in entry + assert "27.0m" in entry + + def test_enhances_existing_ship_normally(self, gen_module, tmp_path): + """Ships present in loc still get the normal base+stats treatment.""" + self._make_ship_xml(tmp_path, "AEGS_Test_Ship", "vehicle_DescAEGS_TestShip") + loc = {"vehicle_DescAEGS_TestShip": "A fast light fighter."} + + result = gen_module.scan_spaceships( + tmp_path, controller_lookup={}, loc=loc, + ) + assert "vehicle_DescAEGS_TestShip" in result + assert "A fast light fighter." in result["vehicle_DescAEGS_TestShip"] + + def test_skips_ai_variants(self, gen_module, tmp_path): + """AI variants are still skipped even with discovery enabled.""" + self._make_ship_xml(tmp_path, "AEGS_Test_pu_ai_Ship", "vehicle_DescAEGS_AI") + + result = gen_module.scan_spaceships( + tmp_path, controller_lookup={}, loc={}, + ) + assert "vehicle_DescAEGS_AI" not in result + + +# ── Parser integration ────────────────────────────────────────────────────── + +class TestDiscoveredItemsInParser: + """Covers that discovered items get 'New' status through the parser.""" + + def test_enhancement_only_key_gets_new_status(self, tmp_path): + """A key only in the enhancements source gets status 'New'.""" + from src.parser.ini_parser import parse_ini_file, load_source_files + + # Simulate: base.ini has key A, enhancement INI has keys A and B + base_ini = tmp_path / "base.ini" + base_ini.write_text("item_DescSHLD_A=Shield A\n", encoding="utf-8") + + enhancement_ini = tmp_path / "enhancements.ini" + enhancement_ini.write_text( + "item_DescSHLD_A=Shield A\n\\n\\n--- STATS ---\\nHP: 1000\n" + "item_DescSHLD_B=Discovered Shield\n\\n\\n--- STATS ---\\nHP: 500\n", + encoding="utf-8", + ) + + base = parse_ini_file(base_ini) + enhancement = parse_ini_file(enhancement_ini) + + assert "item_DescSHLD_A" in base + assert "item_DescSHLD_B" in enhancement + assert "item_DescSHLD_B" not in base + + +# ── append_enhancements guard ──────────────────────────────────────────────── + +class TestAppendEnhancementsGuard: + """Covers the None guard in ``append_enhancements``.""" + + def test_none_existing_value(self, gen_module): + """None existing_value is treated as empty string.""" + result = gen_module.append_enhancements(None, "HP: 500") + assert "HP: 500" in result + + def test_empty_existing_value(self, gen_module): + """Empty existing_value gets just the separator + block.""" + result = gen_module.append_enhancements("", "HP: 500") + assert "HP: 500" in result + + def test_normal_existing_value(self, gen_module): + """Normal existing_value gets separator + block appended.""" + result = gen_module.append_enhancements("Base text.", "HP: 500") + assert "Base text." in result + assert "HP: 500" in result