Files
cosmoteer-pressure-relief-r…/tests/test_mod.py
T
TotenKnopfandClaude Fable 5 ad17425508 feat: v4.0.0 -- radiator switch picks the mode, ammo gate removed
ON vents constantly (vanilla). OFF banks heat and vents only when the
network is nearly full. The gate is now PowerToggle OR MustVent.

The launcher's thermal-ammo broadcast is gone: it did not follow the ammo
selection live, and the switch states the player's intent directly. Only
the radiator is patched now. New test_gate_is_power_or_must_vent pins the
semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oLWY1G6S8U35bi2FxVWni
2026-08-29 01:44:06 +02:00

773 lines
31 KiB
Python

# ABOUT ME
# Validation suite for the Auto Radiator mod.
#
# Why this exists: Cosmoteer applies a mod's "Actions" to vanilla data at load
# time. A path that does not resolve, a component reference that points at
# nothing, or a misspelled component Type all fail the same way -- the game
# crashes on startup and leaves a log file. These tests catch all three offline,
# against the player's actual installed game data, before the game is launched.
#
# Run: python3 tests/test_mod.py
# The game install is found automatically, or set COSMOTEER_DATA to override.
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import rules_parser
MOD_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CANDIDATE_DATA_DIRS = [
"/mnt/SSD4TB/SteamLibrary/steamapps/common/Cosmoteer/Data",
os.path.expanduser("~/.local/share/Steam/steamapps/common/Cosmoteer/Data"),
os.path.expanduser("~/.steam/steam/steamapps/common/Cosmoteer/Data"),
]
# Component fields whose value names another component in the same part.
COMPONENT_REF_FIELDS = {
"OperationalToggle",
"Toggle",
"ToggleSource",
"ValueFrom",
"ValueSource",
"ResourceStorage",
"StartTrigger",
"StopTrigger",
"ResetTrigger",
"OnTrigger",
"OffTrigger",
"Trigger",
"FromStorage",
"ToStorage",
}
_failures = []
_checks = 0
def check(condition, message):
global _checks
_checks += 1
if not condition:
_failures.append(message)
def find_data_dir():
override = os.environ.get("COSMOTEER_DATA")
if override:
return override
for path in CANDIDATE_DATA_DIRS:
if os.path.isdir(path):
return path
return None
DATA = find_data_dir()
_vanilla_cache = {}
def vanilla(rel_path):
"""Parse a vanilla .rules file, relative to the Data folder."""
if rel_path not in _vanilla_cache:
full = os.path.join(DATA, rel_path)
_vanilla_cache[rel_path] = rules_parser.parse_file(full) if os.path.isfile(full) else None
return _vanilla_cache[rel_path]
def split_target(target):
"""Split '<file.rules>/A/B' into ('file.rules', 'A/B')."""
match = re.match(r"^<([^>]+)>/?(.*)$", target.strip().strip('"'))
if not match:
return None, None
return match.group(1), match.group(2)
def resolve(target):
"""Resolve a mod action target against vanilla data. Returns a Node or None."""
rel_path, inner = split_target(target)
if rel_path is None:
return None
root = vanilla(rel_path)
if root is None:
return None
return root.get(inner) if inner else root
# ---------------------------------------------------------------------------
# 1. Parser self-check, against facts read directly out of the vanilla files.
# If these fail the parser is broken and every later result is meaningless.
# ---------------------------------------------------------------------------
def test_parser_reads_vanilla_correctly():
radiator = vanilla("ships/terran/radiator/radiator.rules")
check(radiator is not None, "cannot read vanilla radiator.rules")
if radiator is None:
return
power = radiator.get("Part/Components/PowerToggle")
check(power is not None, "radiator PowerToggle not found by parser")
check(
power is not None and power.get("Type").value == "UIToggle",
"radiator PowerToggle should be a UIToggle",
)
converter = radiator.get("Part/Components/HeatDissipationConverter")
check(converter is not None, "radiator HeatDissipationConverter not found")
check(
converter is not None and converter.get("OperationalToggle").value == "PowerToggle",
"vanilla HeatDissipationConverter should gate on PowerToggle",
)
battery = vanilla("ships/terran/thermal_battery/thermal_battery.rules")
storage = battery.get("Part/Components/HeatStorage") if battery else None
check(storage is not None, "thermal battery HeatStorage not found")
check(
storage is not None and storage.get("Type").value == "ResourceStorage",
"thermal battery HeatStorage should be a ResourceStorage",
)
overclock = vanilla("ships/base_part_overclock.rules")
overflow = overclock.get("Part/Components/OverflowHeatStorage") if overclock else None
check(overflow is not None, "base_part_overclock OverflowHeatStorage not found")
check(
overflow is not None and overflow.get("Type").value == "ResourceConsumingStorage",
"OverflowHeatStorage should be a ResourceConsumingStorage",
)
# ---------------------------------------------------------------------------
# 2. Every path the mod's actions touch must exist in vanilla data.
# ---------------------------------------------------------------------------
def iter_actions(mod):
actions = mod.get("Actions")
if actions is None:
return []
return [actions.children[key] for key in actions.order]
def test_action_targets_resolve():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
actions = iter_actions(mod)
check(len(actions) > 0, "mod.rules declares no Actions")
for action in actions:
kind = action.get("Action")
kind = kind.value if kind else "?"
for field in ("Replace", "OverrideIn", "AddTo", "AddBaseTo", "Remove"):
node = action.get(field)
if node is None or node.kind != "scalar":
continue
target = node.value
check(
resolve(target) is not None,
f"{kind} action target does not exist in vanilla data: {target}",
)
# ---------------------------------------------------------------------------
# 3. Component references must resolve.
# A component the mod adds may reference its own siblings or any component
# already on the vanilla part it is being merged into.
# ---------------------------------------------------------------------------
def added_component_sets():
"""Yield (mod_file, vanilla_components_node) for each AddBase action."""
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
for action in iter_actions(mod):
kind = action.get("Action")
if not kind or kind.value != "AddBase":
continue
target_node = action.get("AddBaseTo")
base_node = action.get("BaseToAdd")
if target_node is None or base_node is None:
continue
base_ref = base_node.value.strip()
match = re.match(r"^&?<([^>]+)>$", base_ref)
check(match is not None, f"cannot parse BaseToAdd reference: {base_ref}")
if match is None:
continue
mod_file = os.path.join(MOD_ROOT, match.group(1))
check(os.path.isfile(mod_file), f"BaseToAdd file missing: {match.group(1)}")
if not os.path.isfile(mod_file):
continue
yield mod_file, resolve(target_node.value)
def test_component_references_resolve():
for mod_file, vanilla_components in added_component_sets():
added = rules_parser.parse_file(mod_file)
name = os.path.basename(mod_file)
check(
vanilla_components is not None,
f"{name}: AddBaseTo target missing from vanilla data",
)
if vanilla_components is None:
continue
known = set(added.order) | set(vanilla_components.order)
for component_name in added.order:
component = added.children[component_name]
if component.kind != "group":
continue
for field in component.order:
if field not in COMPONENT_REF_FIELDS:
continue
value = component.children[field]
if value.kind != "scalar":
continue
ref = value.value.strip()
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", ref):
continue # a path or expression, not a plain sibling name
check(
ref in known,
f"{name}: {component_name}.{field} references unknown component '{ref}'",
)
toggles = component.get("Toggles")
if toggles is not None and toggles.kind == "list":
for key in toggles.order:
entry = toggles.children[key]
if entry.kind == "group":
# Inline form: { Toggle = Something; Invert = true }
inner = entry.get("Toggle")
ref = inner.value.strip() if inner is not None else None
check(
ref is not None,
f"{name}: {component_name}.Toggles has an inline entry with no Toggle",
)
else:
ref = entry.value.strip()
if ref is None:
continue
check(
ref in known,
f"{name}: {component_name}.Toggles references unknown component '{ref}'",
)
# ---------------------------------------------------------------------------
# 4. Every component Type the mod uses must be a Type the engine already knows,
# proven by it appearing somewhere in vanilla data. Catches misspellings.
# ---------------------------------------------------------------------------
_vanilla_types = None
def vanilla_types():
global _vanilla_types
if _vanilla_types is None:
found = set()
pattern = re.compile(r"Type\s*=\s*([A-Za-z_][A-Za-z0-9_]*)")
for root, _dirs, files in os.walk(DATA):
for filename in files:
if not filename.endswith(".rules"):
continue
with open(os.path.join(root, filename), "r", encoding="utf-8", errors="replace") as handle:
found.update(pattern.findall(handle.read()))
_vanilla_types = found
return _vanilla_types
def test_component_types_exist():
for mod_file, _ in added_component_sets():
added = rules_parser.parse_file(mod_file)
name = os.path.basename(mod_file)
for component_name in added.order:
component = added.children[component_name]
if component.kind != "group":
continue
type_node = component.get("Type")
check(type_node is not None, f"{name}: {component_name} has no Type")
if type_node is None:
continue
check(
type_node.value in vanilla_types(),
f"{name}: {component_name} uses unknown component Type '{type_node.value}'",
)
# ---------------------------------------------------------------------------
# 5. Signals must use a mechanism that works on a physical ship.
# Regression guard: version 1.0.0 broadcast with NetworkToggle, which loaded
# without error and did nothing in game. Every NetworkToggle and
# NetworkToggleReceiver in vanilla sets IncludeInPhysical = false -- they are
# blueprint stat-display plumbing only. NetworkValue carries the signal on a
# real ship, and is what vanilla's thermal amplification pump uses.
# ---------------------------------------------------------------------------
BLUEPRINT_ONLY_TYPES = {"NetworkToggle", "NetworkToggleReceiver"}
def test_no_blueprint_only_signal_types():
for mod_file, _ in added_component_sets():
added = rules_parser.parse_file(mod_file)
name = os.path.basename(mod_file)
for component_name in added.order:
component = added.children[component_name]
if component.kind != "group":
continue
type_node = component.get("Type")
if type_node is None:
continue
check(
type_node.value not in BLUEPRINT_ONLY_TYPES,
f"{name}: {component_name} uses '{type_node.value}', which vanilla only ever "
f"uses with IncludeInPhysical = false and which carries no signal in game",
)
def test_vanilla_marks_those_types_blueprint_only():
"""The premise of the guard above, re-checked against the installed game."""
pattern = re.compile(
r"Type\s*=\s*(NetworkToggle|NetworkToggleReceiver)\b(.*?)(?=\n\s*\})",
re.DOTALL,
)
sites = 0
physical_false = 0
for root, _dirs, files in os.walk(DATA):
for filename in files:
if not filename.endswith(".rules"):
continue
with open(os.path.join(root, filename), "r", encoding="utf-8", errors="replace") as handle:
for match in pattern.finditer(handle.read()):
sites += 1
if "IncludeInPhysical = false" in match.group(2):
physical_false += 1
check(sites > 0, "expected vanilla to use NetworkToggle somewhere")
check(
sites == physical_false,
f"premise broken: {sites - physical_false} of {sites} vanilla NetworkToggle sites "
f"are physical, so the guard in test_no_blueprint_only_signal_types may be wrong",
)
# ---------------------------------------------------------------------------
# 6. Added components must not silently overwrite a vanilla component.
# ---------------------------------------------------------------------------
def test_no_name_collisions_with_vanilla():
for mod_file, vanilla_components in added_component_sets():
if vanilla_components is None:
continue
added = rules_parser.parse_file(mod_file)
name = os.path.basename(mod_file)
for component_name in added.order:
check(
component_name not in vanilla_components.children,
f"{name}: '{component_name}' already exists on the vanilla part and would "
f"be overwritten",
)
# ---------------------------------------------------------------------------
# 6b. Every field written on a component must exist in that component's real
# schema, read out of the game assembly's metadata.
#
# Cosmoteer ignores fields it does not recognise rather than reporting them,
# so a misspelled or invented field name produces a mod that loads perfectly
# and silently does nothing. This is the only check that catches that.
# ---------------------------------------------------------------------------
ASSEMBLY_CANDIDATES = [
os.path.join(os.path.dirname(DATA or ""), "Bin", "Cosmoteer.dll") if DATA else "",
]
def find_assembly():
override = os.environ.get("COSMOTEER_ASSEMBLY")
if override and os.path.isfile(override):
return override
for path in ASSEMBLY_CANDIDATES:
if path and os.path.isfile(path):
return path
return None
def test_component_fields_exist_in_schema():
assembly = find_assembly()
if assembly is None:
print(" (skipped: Cosmoteer.dll not found)")
return
sys.path.insert(0, os.path.join(MOD_ROOT, "tools"))
import dotnet_enum
tables = dotnet_enum.load(assembly)
for mod_file, _ in added_component_sets():
added = rules_parser.parse_file(mod_file)
name = os.path.basename(mod_file)
for component_name in added.order:
component = added.children[component_name]
if component.kind != "group":
continue
type_node = component.get("Type")
if type_node is None:
continue
schema = dotnet_enum.schema(tables, f"Part{type_node.value}Rules")
check(schema is not None, f"{name}: no schema found for Type '{type_node.value}'")
if schema is None:
continue
# Some members are complex objects the serializer accepts inline,
# so their own fields are legal at the component's top level too.
for member, inline_type in (("NetworkFilter", "PartNetworkFilter"),):
if member in schema:
nested = dotnet_enum.schema(tables, inline_type)
if nested:
schema = schema | nested
for field in component.order:
if field == "Type":
continue
# ALL_CAPS entries are local constants for &references, the
# vanilla convention (thermal_battery.rules: MAX_STATUS_HEAT).
if field.isupper():
continue
check(
field in schema,
f"{name}: {component_name} ({type_node.value}) has no field "
f"'{field}' -- it would be silently ignored",
)
# ---------------------------------------------------------------------------
# 6c. Every network component the mod adds must be reachable by a route.
#
# A NetworkValue or NetworkValueReceiver is only attached to the network if
# a route leads to it: the part's NetworkPort anchors on one component, and
# a NetworkRouter bridges that anchor to the rest. An unrouted network
# component is not an error -- it simply never connects and always reads as
# absent. That is exactly how v1.1.0 and v1.2.0 failed: they loaded clean,
# the components existed on the parts, and every signal read zero.
# ---------------------------------------------------------------------------
NETWORK_COMPONENT_TYPES = {"NetworkValue", "NetworkValueReceiver", "NetworkResourceInput"}
def collect_route_names(node, found=None):
"""Every component name appearing in any Routes list under this node."""
if found is None:
found = set()
if node.kind not in ("group", "list"):
return found
for name in node.order:
child = node.children[name]
if name == "Routes" and child.kind == "list":
for key in child.order:
route = child.children[key]
if route.kind == "list":
for slot in route.order:
value = route.children[slot]
if value.kind == "scalar":
found.add(value.value.strip())
collect_route_names(child, found)
return found
def addbase_targets():
"""Yield (mod_file_path, target_part_rules_path) for each AddBase action."""
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
for action in iter_actions(mod):
kind = action.get("Action")
if not kind or kind.value != "AddBase":
continue
target = action.get("AddBaseTo")
base = action.get("BaseToAdd")
if target is None or base is None:
continue
match = re.match(r"^&?<([^>]+)>$", base.value.strip())
if match is None:
continue
part_path, _inner = split_target(target.value)
yield os.path.join(MOD_ROOT, match.group(1)), part_path
def test_network_components_are_routed():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
# Routes the mod contributes, keyed by the part they land on.
routed = {}
for mod_file, part_path in addbase_targets():
added = rules_parser.parse_file(mod_file)
routed.setdefault(part_path, set()).update(collect_route_names(added))
for action in iter_actions(mod):
kind = action.get("Action")
add_to = action.get("AddTo")
if not kind or kind.value != "AddMany" or add_to is None:
continue
if "Routes" not in add_to.value:
continue
part_path, _inner = split_target(add_to.value)
entries = action.get("ManyToAdd")
if entries is None:
continue
for key in entries.order:
route = entries.children[key]
if route.kind != "list":
continue
for slot in route.order:
value = route.children[slot]
if value.kind == "scalar":
routed.setdefault(part_path, set()).add(value.value.strip())
for mod_file, part_path in addbase_targets():
added = rules_parser.parse_file(mod_file)
name = os.path.basename(mod_file)
reachable = routed.get(part_path, set())
for component_name in added.order:
component = added.children[component_name]
if component.kind != "group":
continue
type_node = component.get("Type")
if type_node is None or type_node.value not in NETWORK_COMPONENT_TYPES:
continue
check(
component_name in reachable,
f"{name}: {component_name} ({type_node.value}) is not named in any route, "
f"so it never attaches to the network and always reads as absent",
)
# ---------------------------------------------------------------------------
# 6d. An Overrides value that names a component must name one that exists.
#
# The whole point of the Overrides actions is to repoint vanilla components
# at components this mod adds. If such a value names something that is not
# there -- deleted, renamed, or never written -- the mod still loads and the
# gate silently reverts to doing nothing.
# ---------------------------------------------------------------------------
def test_override_values_name_real_components():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
# What each part offers: its vanilla components plus what the mod adds.
available = {}
for mod_file, part_path in addbase_targets():
added = rules_parser.parse_file(mod_file)
vanilla_components = resolve(f"<{part_path}>/Part/Components")
names = set(added.order)
if vanilla_components is not None:
names |= set(vanilla_components.order)
available.setdefault(part_path, set()).update(names)
for action in iter_actions(mod):
kind = action.get("Action")
target = action.get("OverrideIn")
if not kind or kind.value != "Overrides" or target is None:
continue
part_path, _inner = split_target(target.value)
known = available.get(part_path)
if not known:
continue
overrides = action.get("Overrides")
if overrides is None:
continue
for field in overrides.order:
value = overrides.children[field]
if value.kind != "scalar":
continue
ref = value.value.strip()
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", ref):
continue
check(
ref in known,
f"Overrides sets {field} = '{ref}' on {os.path.basename(part_path)}, "
f"but no such component exists on that part",
)
# ---------------------------------------------------------------------------
# 7. Added indicators must bind to a real toggle and a real atlas sprite.
# ---------------------------------------------------------------------------
def test_added_indicators_are_valid():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
radiator_components = resolve("<ships/terran/radiator/radiator.rules>/Part/Components")
added = rules_parser.parse_file(os.path.join(MOD_ROOT, "components", "radiator_auto.rules"))
known = set(added.order) | set(radiator_components.order if radiator_components else [])
atlas = vanilla("gui/game/indicators/indicators.rules")
check(atlas is not None, "cannot read the indicators atlas")
for action in iter_actions(mod):
kind = action.get("Action")
add_to = action.get("AddTo")
if not kind or kind.value != "AddMany" or add_to is None:
continue
if "Indicators" not in add_to.value:
continue
entries = action.get("ManyToAdd")
check(entries is not None, "indicator AddMany has no ManyToAdd list")
if entries is None:
continue
for key in entries.order:
entry = entries.children[key]
toggle = entry.get("Toggle")
check(toggle is not None, "an added indicator has no Toggle")
if toggle is not None:
check(
toggle.value.strip() in known,
f"added indicator binds to unknown component '{toggle.value.strip()}'",
)
sprite = entry.get("AtlasSprite/File")
check(sprite is not None, "an added indicator has no AtlasSprite/File")
if sprite is not None and atlas is not None:
name = sprite.value.strip().rsplit("/", 1)[-1]
check(
name in atlas.children,
f"added indicator uses unknown atlas sprite '{name}'",
)
# ---------------------------------------------------------------------------
# 8. The mod manifest must declare the fields Cosmoteer requires.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Threshold amounts must live on the same scale as the value they read.
#
# From Cosmoteer.dll (see tools/ildump.py): PartThresholdToggle compares
# ValueFrom.Value against MinAmount/MaxAmount as plain floats -- "5%" is 0.05,
# nothing more. A ResourceStorage, NetworkResourceStore or NetworkResourceInput
# with the default ProvidedValueRange reports a 0..1 fraction (fill for
# storages, free space for inputs). v2.1.0 compared such a fraction against
# 2000000, so its "space low" toggle was permanently on and the mod was a no-op.
# ---------------------------------------------------------------------------
FRACTION_VALUED_TYPES = {"ResourceStorage", "NetworkResourceStore", "NetworkResourceInput"}
def scalar_number(node, scope=None):
"""Turn a scalar like '5%', '0.25' or '&LOCAL_CONSTANT' into a float.
A leading '&NAME' is looked up in the given scope (the component itself),
the vanilla convention for local constants. Returns None if not numeric.
"""
if node is None:
return None
text = node.value.strip()
if text.startswith("&") and scope is not None:
return scalar_number(scope.get(text[1:].strip("()")), None)
try:
if text.endswith("%"):
return float(text[:-1]) / 100.0
return float(text)
except ValueError:
return None
def test_threshold_amounts_match_value_scale():
check(scalar_number(rules_parser.Node("scalar", "5%")) == 0.05, "'5%' should read as 0.05")
scope = rules_parser.parse("K = 40%\nX = &K")
check(scalar_number(scope.get("X"), scope) == 0.4, "'&K' should resolve through the scope")
for mod_file, target in added_component_sets():
added = rules_parser.parse_file(mod_file)
for name in added.order:
comp = added.get(name)
if comp.kind != "group" or comp.get("Type") is None:
continue
if comp.get("Type").value != "ThresholdToggle":
continue
source_name = comp.get("ValueFrom").value if comp.get("ValueFrom") else None
source = added.get(source_name) if source_name else None
if source is None and target is not None:
source = target.get(source_name)
if source is None or source.get("Type") is None:
continue
source_type = source.get("Type").value
if source_type not in FRACTION_VALUED_TYPES:
continue
if source.get("ProvidedValueRange") is not None or source.get("ProvidedValuePerResource") is not None:
continue
for field in ("MinAmount", "MaxAmount"):
amount = scalar_number(comp.get(field), comp)
if amount is None:
continue
check(
0.0 <= amount <= 1.0,
f"{os.path.basename(mod_file)}: {name}.{field} = {amount} but "
f"{source_name} ({source_type}) reports a 0..1 fraction",
)
# ---------------------------------------------------------------------------
# The gate's semantics: radiator button ON = vanilla, always vent; button OFF
# = bank heat, and the mod vents only when the network is nearly full. So the
# gate is PowerToggle OR MustVent, and nothing else -- no ammo criterion, no
# launcher patch, no cross-part signal.
# ---------------------------------------------------------------------------
def test_gate_is_power_or_must_vent():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
for action in iter_actions(mod):
for field in ("AddBaseTo", "OverrideIn", "AddTo"):
node = action.get(field)
check(
node is None or "missile_launcher" not in node.value,
f"mod.rules still patches the missile launcher via {field}",
)
check(
not os.path.exists(os.path.join(MOD_ROOT, "components", "launcher_ammo_signal.rules")),
"components/launcher_ammo_signal.rules should be gone",
)
auto = rules_parser.parse_file(os.path.join(MOD_ROOT, "components", "radiator_auto.rules"))
gate = auto.get("AutoPowerToggle")
check(gate is not None, "AutoPowerToggle missing")
if gate is None:
return
check(gate.get("Mode") is not None and gate.get("Mode").value == "Any", "AutoPowerToggle must be Mode = Any (OR)")
toggles = gate.get("Toggles")
names = [c.value for c in toggles.children.values()] if toggles is not None and toggles.kind == "list" else []
check(sorted(names) == ["AutoRad_MustVent", "PowerToggle"], f"AutoPowerToggle toggles should be exactly PowerToggle + AutoRad_MustVent, got {names}")
for name in auto.order:
check("Ammo" not in name, f"ammo-signal component still present: {name}")
def test_manifest_is_well_formed():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
for field in ("ID", "Name", "Version", "CompatibleGameVersions", "ModifiesGameplay"):
check(field in mod, f"mod.rules is missing required field '{field}'")
mod_id = mod.get("ID")
check(
mod_id is not None and "." in mod_id.value and not mod_id.value.startswith("cosmoteer."),
"mod ID must be 'author.mod_name' and must not use the reserved 'cosmoteer' author",
)
def main():
if DATA is None or not os.path.isdir(DATA):
print("SKIP: Cosmoteer Data folder not found. Set COSMOTEER_DATA to run these tests.")
return 0
print(f"Cosmoteer data: {DATA}\n")
for name, test in sorted(globals().items()):
if name.startswith("test_") and callable(test):
before = len(_failures)
test()
status = "PASS" if len(_failures) == before else "FAIL"
print(f" [{status}] {name}")
print(f"\n{_checks} checks run.")
if _failures:
print(f"{len(_failures)} FAILED:\n")
for failure in _failures:
print(f" - {failure}")
return 1
print("All checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())