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