diff --git a/gui/builtinContextMenus/itemMutations.py b/gui/builtinContextMenus/itemMutations.py index f0b99a4d6..1c8d934d9 100644 --- a/gui/builtinContextMenus/itemMutations.py +++ b/gui/builtinContextMenus/itemMutations.py @@ -5,9 +5,6 @@ import wx import gui.mainFrame from gui.contextMenu import ContextMenuSingle -from gui.fitCommands import ( - GuiConvertMutatedLocalModuleCommand, GuiRevertMutatedLocalModuleCommand, - GuiConvertMutatedLocalDroneCommand, GuiRevertMutatedLocalDroneCommand) from service.fit import Fit _t = wx.GetTranslation @@ -65,6 +62,8 @@ class ChangeItemMutation(ContextMenuSingle): return sub def handleMenu(self, event): + from gui.fitCommands import ( + GuiConvertMutatedLocalModuleCommand, GuiConvertMutatedLocalDroneCommand) mutaplasmid, item = self.eventIDs[event.Id] fitID = self.mainFrame.getActiveFit() fit = Fit.getInstance().getFit(fitID) @@ -78,6 +77,8 @@ class ChangeItemMutation(ContextMenuSingle): fitID=fitID, position=position, mutaplasmid=mutaplasmid)) def activate(self, callingWindow, fullContext, mainItem, i): + from gui.fitCommands import ( + GuiRevertMutatedLocalModuleCommand, GuiRevertMutatedLocalDroneCommand) fitID = self.mainFrame.getActiveFit() fit = Fit.getInstance().getFit(fitID) if mainItem in fit.modules: diff --git a/pyfa.py b/pyfa.py index 31028e6aa..a70b76c64 100755 --- a/pyfa.py +++ b/pyfa.py @@ -74,9 +74,19 @@ parser.add_option("-s", "--savepath", action="store", dest="savepath", help="Set parser.add_option("-l", "--logginglevel", action="store", dest="logginglevel", help="Set desired logging level [Critical|Error|Warning|Info|Debug]", default="Error") parser.add_option("-p", "--profile", action="store", dest="profile_path", help="Set location to save profileing.", default=None) parser.add_option("-i", "--language", action="store", dest="language", help="Sets the language for pyfa. Overrides user's saved settings. Format: xx_YY (eg: en_US). If translation doesn't exist, defaults to en_US", default=None) +parser.add_option("--headless", action="store_true", dest="headless", help="Run CLI stats only: read JSON fit from first positional arg (or stdin), print stats JSON to stdout, then exit. No GUI.", default=False) (options, args) = parser.parse_args() +if getattr(options, "headless", False): + from scripts.pyfa_cli_stats import main as headless_main + headless_argv = [] + if options.savepath: + headless_argv.extend(["--savepath", options.savepath]) + if args: + headless_argv.append(args[0]) + sys.exit(headless_main(headless_argv)) + if __name__ == "__main__": try: diff --git a/pyproject.toml b/pyproject.toml index f805d94a9..d9a8a19e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,3 +22,6 @@ dependencies = [ "sqlalchemy==1.4.50", "wxpython==4.2.1", ] + +[project.optional-dependencies] +dev = ["pytest"] diff --git a/scripts/pyfa_cli_stats.py b/scripts/pyfa_cli_stats.py new file mode 100644 index 000000000..f37b12b45 --- /dev/null +++ b/scripts/pyfa_cli_stats.py @@ -0,0 +1,411 @@ +import argparse +import json +import sys + +import config +import eos.config + +from eos.const import FittingHardpoint +from eos.utils.spoolSupport import SpoolOptions, SpoolType + + +def _init_pyfa(savepath: str | None) -> None: + config.debug = False + config.loggingLevel = config.LOGLEVEL_MAP["error"] + config.defPaths(savepath) + config.defLogging() + import eos.db # noqa: F401 + eos.db.saveddata_meta.create_all() # type: ignore[name-defined] + import eos.events # noqa: F401 + from service import prefetch # noqa: F401 + + +def _parse_drone_markers(text: str) -> tuple[str, set[str]]: + active_names: set[str] = set() + cleaned_lines: list[str] = [] + for raw_line in text.splitlines(): + line = raw_line.rstrip() + if " xx" in line: + idx = line.find(" xx") + marker_segment = line[idx + 1 :].strip() + + if marker_segment == "xx": + payload = line[:idx].rstrip() + parts = payload.split(" x", 1) + if len(parts) == 2: + type_name = parts[0].strip() + if type_name: + active_names.add(type_name) + line = payload + + cleaned_lines.append(line) + + return "\n".join(cleaned_lines), active_names + + +def _import_single_fit(raw_text: str) -> tuple[object, set[str]]: + from service.port.port import Port + + cleaned_text, active_names = _parse_drone_markers(raw_text) + import_type, import_data = Port.importFitFromBuffer(cleaned_text) + + if not import_data or len(import_data) != 1: + raise SystemExit("Expected exactly one fit in input; got %d" % (len(import_data) if import_data else 0)) + + fit = import_data[0] + + if active_names: + for drone in fit.drones: + if getattr(drone.item, "typeName", None) in active_names: + drone.amountActive = drone.amount + for fighter in fit.fighters: + if getattr(fighter.item, "typeName", None) in active_names: + fighter.amount = fighter.amount + + return fit, active_names + + +def _add_projected_fit(s_fit, target_fit, projected_fit, amount: int) -> None: + fit = s_fit.getFit(target_fit.ID) + projected = s_fit.getFit(projected_fit.ID, projected=True) + if projected is None: + raise SystemExit("Projected fit %s is not available" % projected_fit.ID) + + if projected in fit.projectedFits and projected.ID in fit.projectedFitDict: + projection_info = projected.getProjectionInfo(fit.ID) + if projection_info is None: + raise SystemExit("Projection info missing for projected fit %s" % projected_fit.ID) + else: + fit.projectedFitDict[projected.ID] = projected + eos.db.saveddata_session.flush() + eos.db.saveddata_session.refresh(projected) + projection_info = projected.getProjectionInfo(fit.ID) + if projection_info is None: + raise SystemExit("Projection info missing after linking projected fit %s" % projected_fit.ID) + + projection_info.amount = amount + projection_info.active = True + + +def _add_command_fit(s_fit, target_fit, command_fit) -> None: + fit = s_fit.getFit(target_fit.ID) + command = s_fit.getFit(command_fit.ID) + if command is None: + raise SystemExit("Command fit %s is not available" % command_fit.ID) + + if command in fit.commandFits or command.ID in fit.commandFitDict: + return + + fit.commandFitDict[command.ID] = command + eos.db.saveddata_session.flush() + eos.db.saveddata_session.refresh(command) + info = command.getCommandInfo(fit.ID) + if info is None: + raise SystemExit("Command info missing for command fit %s" % command_fit.ID) + info.active = True + + +def _collect_resources(fit) -> dict: + ship = fit.ship + resources: dict = {} + + resources["hardpoints"] = { + "turret": { + "used": fit.getHardpointsUsed(FittingHardpoint.TURRET), + "total": ship.getModifiedItemAttr("turretSlotsLeft"), + }, + "launcher": { + "used": fit.getHardpointsUsed(FittingHardpoint.MISSILE), + "total": ship.getModifiedItemAttr("launcherSlotsLeft"), + }, + } + + resources["drones"] = { + "active": fit.activeDrones, + "max_active": fit.extraAttributes.get("maxActiveDrones"), + "bay_used": fit.droneBayUsed, + "bay_capacity": ship.getModifiedItemAttr("droneCapacity"), + "bandwidth_used": fit.droneBandwidthUsed, + "bandwidth_capacity": ship.getModifiedItemAttr("droneBandwidth"), + } + + resources["fighters"] = { + "tubes_used": fit.fighterTubesUsed, + "tubes_total": fit.fighterTubesTotal, + "bay_used": fit.fighterBayUsed, + "bay_capacity": ship.getModifiedItemAttr("fighterCapacity"), + } + + resources["calibration"] = { + "used": fit.calibrationUsed, + "total": ship.getModifiedItemAttr("upgradeCapacity"), + } + + resources["powergrid"] = { + "used": fit.pgUsed, + "output": ship.getModifiedItemAttr("powerOutput"), + } + + resources["cpu"] = { + "used": fit.cpuUsed, + "output": ship.getModifiedItemAttr("cpuOutput"), + } + + resources["cargo"] = { + "used": fit.cargoBayUsed, + "capacity": ship.getModifiedItemAttr("capacity"), + } + + return resources + + +def _collect_defense(fit) -> dict: + ship = fit.ship + + def _res(attr_name): + return ship.getModifiedItemAttr(attr_name) + + resonance = { + "armor": {"em": _res("armorEmDamageResonance"), "exp": _res("armorExplosiveDamageResonance"), "kin": _res("armorKineticDamageResonance"), "therm": _res("armorThermalDamageResonance")}, + "shield": {"em": _res("shieldEmDamageResonance"), "exp": _res("shieldExplosiveDamageResonance"), "kin": _res("shieldKineticDamageResonance"), "therm": _res("shieldThermalDamageResonance")}, + "hull": {"em": _res("emDamageResonance"), "exp": _res("explosiveDamageResonance"), "kin": _res("kineticDamageResonance"), "therm": _res("thermalDamageResonance")}, + } + defense: dict = {} + defense["hp"] = fit.hp + defense["ehp"] = fit.ehp + defense["resonance"] = resonance + defense["tank"] = fit.tank + defense["effective_tank"] = fit.effectiveTank + defense["sustainable_tank"] = fit.sustainableTank + defense["effective_sustainable_tank"] = fit.effectiveSustainableTank + return defense + + +def _collect_capacitor(fit) -> dict: + ship = fit.ship + cap: dict = {} + cap["capacity"] = ship.getModifiedItemAttr("capacitorCapacity") + cap["recharge"] = fit.capRecharge + cap["use"] = fit.capUsed + cap["delta"] = fit.capDelta + cap["stable"] = fit.capStable + cap["state"] = fit.capState + cap["neutralizer_resistance"] = ship.getModifiedItemAttr("energyWarfareResistance", 1) + return cap + + +def _collect_firepower(fit) -> dict: + default_spool = eos.config.settings["globalDefaultSpoolupPercentage"] + spool = SpoolOptions(SpoolType.SPOOL_SCALE, default_spool, False) + wdps = fit.getWeaponDps(spoolOptions=spool) + ddps = fit.getDroneDps() + wvol = fit.getWeaponVolley(spoolOptions=spool) + dvol = fit.getDroneVolley() + return { + "weapon_dps": wdps.total, + "drone_dps": ddps.total, + "total_dps": fit.getTotalDps(spoolOptions=spool).total, + "weapon_volley": wvol.total, + "drone_volley": dvol.total, + "total_volley": fit.getTotalVolley(spoolOptions=spool).total, + "weapons": [], + } + + +def _collect_remote_reps(fit) -> dict: + default_spool = eos.config.settings["globalDefaultSpoolupPercentage"] + pre = fit.getRemoteReps(spoolOptions=SpoolOptions(SpoolType.SPOOL_SCALE, 0, True)) + full = fit.getRemoteReps(spoolOptions=SpoolOptions(SpoolType.SPOOL_SCALE, 1, True)) + current = fit.getRemoteReps(spoolOptions=SpoolOptions(SpoolType.SPOOL_SCALE, default_spool, False)) + return { + "current": { + "capacitor": current.capacitor, + "shield": current.shield, + "armor": current.armor, + "hull": current.hull, + }, + "pre_spool": { + "capacitor": pre.capacitor, + "shield": pre.shield, + "armor": pre.armor, + "hull": pre.hull, + }, + "full_spool": { + "capacitor": full.capacitor, + "shield": full.shield, + "armor": full.armor, + "hull": full.hull, + }, + } + + +def _collect_targeting_misc(fit) -> dict: + ship = fit.ship + misc: dict = {} + misc["targets_max"] = fit.maxTargets + misc["target_range"] = fit.maxTargetRange + misc["scan_resolution"] = ship.getModifiedItemAttr("scanResolution") + misc["scan_strength"] = fit.scanStrength + misc["scan_type"] = fit.scanType + misc["jam_chance"] = fit.jamChance + misc["drone_control_range"] = fit.extraAttributes.get("droneControlRange") + misc["speed"] = fit.maxSpeed + misc["align_time"] = fit.alignTime + misc["signature_radius"] = ship.getModifiedItemAttr("signatureRadius") + misc["warp_speed"] = fit.warpSpeed + misc["max_warp_distance"] = fit.maxWarpDistance + misc["probe_size"] = fit.probeSize + misc["cargo_capacity"] = ship.getModifiedItemAttr("capacity") + misc["cargo_used"] = fit.cargoBayUsed + return misc + + +def _collect_price(fit) -> dict: + ship_price = 0.0 + module_price = 0.0 + drone_price = 0.0 + fighter_price = 0.0 + cargo_price = 0.0 + booster_price = 0.0 + implant_price = 0.0 + + if fit: + ship_price = getattr(fit.ship.item.price, "price", 0.0) + + for module in fit.modules: + if not module.isEmpty: + module_price += getattr(module.item.price, "price", 0.0) + + for drone in fit.drones: + drone_price += getattr(drone.item.price, "price", 0.0) * drone.amount + + for fighter in fit.fighters: + fighter_price += getattr(fighter.item.price, "price", 0.0) * fighter.amount + + for cargo in fit.cargo: + cargo_price += getattr(cargo.item.price, "price", 0.0) * cargo.amount + + for booster in fit.boosters: + booster_price += getattr(booster.item.price, "price", 0.0) + + for implant in fit.appliedImplants: + implant_price += getattr(implant.item.price, "price", 0.0) + + total_price = ship_price + module_price + drone_price + fighter_price + cargo_price + booster_price + implant_price + + return { + "ship": ship_price, + "fittings": module_price, + "drones_and_fighters": drone_price + fighter_price, + "cargo": cargo_price, + "character": booster_price + implant_price, + "total": total_price, + } + + +def _build_output(main_fit) -> dict: + return { + "fit": { + "id": main_fit.ID, + "name": main_fit.name, + "ship_type": main_fit.ship.item.typeName, + }, + "resources": _collect_resources(main_fit), + "defense": _collect_defense(main_fit), + "capacitor": _collect_capacitor(main_fit), + "firepower": _collect_firepower(main_fit), + "remote_reps_outgoing": _collect_remote_reps(main_fit), + "targeting_misc": _collect_targeting_misc(main_fit), + "price": _collect_price(main_fit), + } + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Headless pyfa CLI fit statistics exporter") + parser.add_argument( + "data", + nargs="?", + help="JSON payload (inline). If omitted, read from stdin.", + ) + parser.add_argument( + "--savepath", + help="Override pyfa save path (saveddata, DB, logs). Defaults to standard user location.", + ) + return parser.parse_args(argv) + + +def _load_payload(data: str) -> dict: + try: + payload = json.loads(data) + except json.JSONDecodeError as exc: + raise SystemExit("Invalid JSON: %s" % exc) from exc + if not isinstance(payload, dict): + raise SystemExit("Top-level JSON must be an object") + return payload + + +def main(argv: list[str] | None = None) -> int: + if argv is None: + argv = sys.argv[1:] + + args = _parse_args(argv) + _init_pyfa(args.savepath) + + from service.fit import Fit as FitService + + s_fit = FitService.getInstance() + if s_fit.character is None: + from eos.saveddata.character import Character as saveddata_Character + s_fit.character = saveddata_Character.getAll5() + + raw = args.data if args.data is not None else sys.stdin.read() + payload = _load_payload(raw) + + if "fit" not in payload or not isinstance(payload["fit"], str): + raise SystemExit("Payload must contain a 'fit' field with EFT/text export") + + main_fit, _ = _import_single_fit(payload["fit"]) + + projected_defs = payload.get("projected_fits", []) + command_defs = payload.get("command_fits", []) + + projected_fits: list[tuple[object, int]] = [] + for entry in projected_defs: + if not isinstance(entry, dict): + raise SystemExit("Each projected_fits entry must be an object") + fit_text = entry.get("fit") + count = entry.get("count") + if not isinstance(fit_text, str): + raise SystemExit("Each projected_fits entry must contain a string 'fit'") + if not isinstance(count, int) or count <= 0: + raise SystemExit("Each projected_fits entry must contain a positive integer 'count'") + pf, _ = _import_single_fit(fit_text) + projected_fits.append((pf, count)) + + command_fits: list[object] = [] + for entry in command_defs: + if not isinstance(entry, dict): + raise SystemExit("Each command_fits entry must be an object") + fit_text = entry.get("fit") + if not isinstance(fit_text, str): + raise SystemExit("Each command_fits entry must contain a string 'fit'") + cf, _ = _import_single_fit(fit_text) + command_fits.append(cf) + + for pf, count in projected_fits: + _add_projected_fit(s_fit, main_fit, pf, count) + for cf in command_fits: + _add_command_fit(s_fit, main_fit, cf) + + s_fit.recalc(main_fit) + s_fit.fill(main_fit) + + output = _build_output(main_fit) + json.dump(output, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/service/esi.py b/service/esi.py index e176e223f..cded52516 100644 --- a/service/esi.py +++ b/service/esi.py @@ -13,11 +13,9 @@ from service.const import EsiLoginMethod, EsiSsoMode from eos.saveddata.ssocharacter import SsoCharacter from service.esiAccess import APIException, GenericSsoError import gui.globalEvents as GE -from gui.ssoLogin import SsoLogin from service.server import StoppableHTTPServer, AuthHandler from service.settings import EsiSettings from service.esiAccess import EsiAccess -import gui.mainFrame from requests import Session @@ -140,6 +138,7 @@ class Esi(EsiAccess): self.fittings_deleted.add(fittingID) def login(self): + import gui.ssoLogin start_server = self.settings.get('loginMode') == EsiLoginMethod.SERVER and self.server_base.supports_auto_login with gui.ssoLogin.SsoLogin(self.server_base, start_server) as dlg: if dlg.ShowModal() == wx.ID_OK: diff --git a/service/port/__init__.py b/service/port/__init__.py index e7b0967a1..9664b7bd9 100644 --- a/service/port/__init__.py +++ b/service/port/__init__.py @@ -1,2 +1,8 @@ from .efs import EfsPort -from .port import Port + + +def __getattr__(name): + if name == "Port": + from service.port.port import Port + return Port + raise AttributeError("module %r has no attribute %r" % (__name__, name)) diff --git a/service/port/dna.py b/service/port/dna.py index 09e766d80..611d7a331 100644 --- a/service/port/dna.py +++ b/service/port/dna.py @@ -31,7 +31,6 @@ from eos.saveddata.fighter import Fighter from eos.saveddata.fit import Fit from eos.saveddata.module import Module from eos.saveddata.ship import Ship -from gui.fitCommands.helpers import activeStateLimit from service.const import PortDnaOptions from service.fit import Fit as svcFit from service.market import Market @@ -80,6 +79,7 @@ def importDnaAlt(string, fitName=None): return processImportInfo(info, fitName, "*") def processImportInfo(info, fitName, amountSeparator): + from gui.fitCommands.helpers import activeStateLimit sMkt = Market.getInstance() f = Fit() try: diff --git a/service/port/efs.py b/service/port/efs.py index 55358b644..b6b7be152 100755 --- a/service/port/efs.py +++ b/service/port/efs.py @@ -16,10 +16,6 @@ from eos.effectHandlerHelpers import HandledList from eos.db import gamedata_session, getCategory, getAttributeInfo, getGroup from eos.gamedata import Attribute, Effect, Group, Item, ItemEffect from eos.utils.spoolSupport import SpoolType, SpoolOptions -from gui.fitCommands.calc.module.localAdd import CalcAddLocalModuleCommand -from gui.fitCommands.calc.module.localRemove import CalcRemoveLocalModulesCommand -from gui.fitCommands.calc.module.changeCharges import CalcChangeModuleChargesCommand -from gui.fitCommands.helpers import ModuleInfo pyfalog = Logger(__name__) @@ -68,6 +64,9 @@ class EfsPort: if propID is None: return None + from gui.fitCommands.calc.module.localAdd import CalcAddLocalModuleCommand + from gui.fitCommands.calc.module.localRemove import CalcRemoveLocalModulesCommand + from gui.fitCommands.helpers import ModuleInfo cmd = CalcAddLocalModuleCommand(fitID, ModuleInfo(itemID=propID)) cmd.Do() if cmd.needsGuiRecalc: @@ -137,6 +136,7 @@ class EfsPort: EfsPort.attrDirectMap(["reloadTime"], stats, mod) c = mod.charge if c: + from gui.fitCommands.calc.module.changeCharges import CalcChangeModuleChargesCommand sFit.recalc(fit) CalcChangeModuleChargesCommand( fit.ID, diff --git a/service/port/eft.py b/service/port/eft.py index b36b4708d..84b50fbe1 100644 --- a/service/port/eft.py +++ b/service/port/eft.py @@ -33,7 +33,6 @@ from eos.saveddata.fit import Fit from eos.saveddata.implant import Implant from eos.saveddata.module import Module from eos.saveddata.ship import Ship -from gui.fitCommands.helpers import activeStateLimit from service.const import PortEftOptions from service.fit import Fit as svcFit from service.market import Market @@ -241,6 +240,7 @@ def exportCargo(cargos): def importEft(lines): + from gui.fitCommands.helpers import activeStateLimit lines = _importPrepare(lines) try: fit = _importCreateFit(lines) @@ -877,6 +877,7 @@ class AbstractFit: self.getContainerBySlot(m.slot).append(m) def __makeModule(self, itemSpec): + from gui.fitCommands.helpers import activeStateLimit # Mutate item if needed m = None if itemSpec.mutationIdx in self.mutations: diff --git a/service/port/esi.py b/service/port/esi.py index ef708c972..98d5df704 100644 --- a/service/port/esi.py +++ b/service/port/esi.py @@ -31,7 +31,6 @@ from eos.saveddata.fighter import Fighter from eos.saveddata.fit import Fit from eos.saveddata.module import Module from eos.saveddata.ship import Ship -from gui.fitCommands.helpers import activeStateLimit from service.fit import Fit as svcFit from service.market import Market @@ -161,6 +160,7 @@ def exportESI(ofit, exportCharges, exportImplants, exportBoosters, callback): def importESI(string): + from gui.fitCommands.helpers import activeStateLimit sMkt = Market.getInstance() fitobj = Fit() diff --git a/service/port/port.py b/service/port/port.py index cb1099471..c8353db86 100644 --- a/service/port/port.py +++ b/service/port/port.py @@ -195,10 +195,14 @@ class Port: # TODO: catch the exception? # activeFit is reserved?, bufferStr is unicode? (assume only clipboard string? sFit = svcFit.getInstance() + if sFit.character is None: + from eos.saveddata.character import Character as saveddata_Character + sFit.character = saveddata_Character.getAll5() importType, makesNewFits, importData = Port.importAuto(bufferStr, activeFit=activeFit) if makesNewFits: - for fit in importData: + fits = [f for f in importData if f is not None] + for fit in fits: fit.character = sFit.character fit.damagePattern = sFit.pattern fit.targetProfile = sFit.targetProfile @@ -208,6 +212,7 @@ class Port: useCharImplants = sFit.serviceFittingOptions["useCharacterImplantsByDefault"] fit.implantLocation = ImplantLocation.CHARACTER if useCharImplants else ImplantLocation.FIT db.save(fit) + return importType, fits return importType, importData @classmethod diff --git a/service/port/xml.py b/service/port/xml.py index 4fbb161e2..3f7f975a6 100644 --- a/service/port/xml.py +++ b/service/port/xml.py @@ -32,7 +32,6 @@ from eos.saveddata.fighter import Fighter from eos.saveddata.fit import Fit from eos.saveddata.module import Module from eos.saveddata.ship import Ship -from gui.fitCommands.helpers import activeStateLimit from service.fit import Fit as svcFit from service.market import Market from service.port.muta import renderMutantAttrs, parseMutantAttrs @@ -155,6 +154,7 @@ def _resolve_module(hardware, sMkt, b_localized): def importXml(text, progress): + from gui.fitCommands.helpers import activeStateLimit from .port import Port sMkt = Market.getInstance() doc = xml.dom.minidom.parseString(text) diff --git a/tests/test_pyfa_cli_stats.py b/tests/test_pyfa_cli_stats.py new file mode 100644 index 000000000..39ce52847 --- /dev/null +++ b/tests/test_pyfa_cli_stats.py @@ -0,0 +1,62 @@ +import json +import os +import sys +import tempfile +from io import StringIO +from contextlib import redirect_stdout + + +script_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(os.path.realpath(os.path.join(script_dir, ".."))) + +from scripts import pyfa_cli_stats # noqa: E402 + + +ISHTAR_SPIDER_FIT = """[Ishtar, Spider] + +Capacitor Power Relay II +Drone Damage Amplifier II +Explosive Armor Hardener II +Multispectrum Energized Membrane II +Reactive Armor Hardener +Shadow Serpentis EM Armor Hardener + +Cap Recharger II +Omnidirectional Tracking Link II, Tracking Speed Script +Medium Compact Pb-Acid Cap Battery +Republic Fleet Large Cap Battery + +Medium Remote Armor Repairer II +Medium Remote Armor Repairer II +Medium Remote Armor Repairer II +Medium Remote Armor Repairer II + +Medium Explosive Armor Reinforcer II +Medium Thermal Armor Reinforcer II + + +Valkyrie II x5 +Berserker II x5 +""" + + +def test_ishtar_spider_remote_armor_reps(): + payload = { + "fit": ISHTAR_SPIDER_FIT, + } + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp: + argv = ["--savepath", tmp, json.dumps(payload)] + + buf = StringIO() + with redirect_stdout(buf): + rc = pyfa_cli_stats.main(argv) + + assert rc == 0 + + out = buf.getvalue() + data = json.loads(out) + + armor_rps = data["remote_reps_outgoing"]["current"]["armor"] + + assert armor_rps is not None + assert int(round(armor_rps)) == 171 diff --git a/uv.lock b/uv.lock index bf000eadf..871e5d507 100644 --- a/uv.lock +++ b/uv.lock @@ -159,6 +159,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "contourpy" version = "1.3.3" @@ -369,6 +378,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "kiwisolver" version = "1.4.9" @@ -593,6 +611,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pyasn1" version = "0.6.1" @@ -634,6 +661,11 @@ dependencies = [ { name = "wxpython" }, ] +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "beautifulsoup4", specifier = "==4.12.2" }, @@ -643,6 +675,7 @@ requires-dist = [ { name = "matplotlib", specifier = "==3.8.2" }, { name = "numpy", specifier = "==1.26.2" }, { name = "packaging", specifier = "==23.2" }, + { name = "pytest", marker = "extra == 'dev'" }, { name = "python-dateutil", specifier = "==2.8.2" }, { name = "python-jose", specifier = "==3.3.0" }, { name = "pyyaml", specifier = "==6.0.1" }, @@ -653,6 +686,16 @@ requires-dist = [ { name = "sqlalchemy", specifier = "==1.4.50" }, { name = "wxpython", specifier = "==4.2.1" }, ] +provides-extras = ["dev"] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] [[package]] name = "pyparsing" @@ -663,6 +706,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "python-dateutil" version = "2.8.2"