diff --git a/_development/helpers.py b/_development/helpers.py
index 6e0cbac030..cfc07decb9 100644
--- a/_development/helpers.py
+++ b/_development/helpers.py
@@ -92,7 +92,7 @@ class ReadOnlyException(Exception):
return helper
# noinspection PyUnresolvedReferences,PyUnusedLocal
-@pytest.fixture
+@pytest.fixture(name='DB')
def DBInMemory():
print("Creating database in memory")
diff --git a/eve.db b/eve.db
new file mode 100644
index 0000000000..d84451d786
Binary files /dev/null and b/eve.db differ
diff --git a/gui/ammoBreakdown/__init__.py b/gui/ammoBreakdown/__init__.py
new file mode 100644
index 0000000000..1d5a0740fc
--- /dev/null
+++ b/gui/ammoBreakdown/__init__.py
@@ -0,0 +1,20 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+from .frame import AmmoBreakdownFrame
diff --git a/gui/ammoBreakdown/frame.py b/gui/ammoBreakdown/frame.py
new file mode 100644
index 0000000000..db8f600114
--- /dev/null
+++ b/gui/ammoBreakdown/frame.py
@@ -0,0 +1,160 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+import csv
+# noinspection PyPackageRequirements
+import wx
+
+import gui.globalEvents as GE
+import gui.mainFrame
+from gui.auxWindow import AuxiliaryFrame
+from service.ammoBreakdown import get_ammo_breakdown
+from service.fit import Fit
+
+_t = wx.GetTranslation
+
+COL_AMMO_NAME = 0
+COL_DAMAGE_TYPE = 1
+COL_OPTIMAL = 2
+COL_FALLOFF = 3
+COL_ALPHA = 4
+COL_DPS = 5
+
+
+class AmmoBreakdownFrame(AuxiliaryFrame):
+
+ def __init__(self, parent):
+ super().__init__(parent, title=_t('Ammo Breakdown'), size=(640, 400), resizeable=True)
+ self.mainFrame = gui.mainFrame.MainFrame.getInstance()
+ self._data = []
+
+ mainSizer = wx.BoxSizer(wx.VERTICAL)
+
+ self.listCtrl = wx.ListCtrl(
+ self, wx.ID_ANY,
+ style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.BORDER_SUNKEN
+ )
+ self.listCtrl.AppendColumn(_t('Ammo Name'), wx.LIST_FORMAT_LEFT, 180)
+ self.listCtrl.AppendColumn(_t('Damage Type'), wx.LIST_FORMAT_LEFT, 110)
+ self.listCtrl.AppendColumn(_t('Optimal'), wx.LIST_FORMAT_LEFT, 120)
+ self.listCtrl.AppendColumn(_t('Falloff'), wx.LIST_FORMAT_LEFT, 120)
+ self.listCtrl.AppendColumn(_t('Alpha'), wx.LIST_FORMAT_RIGHT, 90)
+ self.listCtrl.AppendColumn(_t('DPS'), wx.LIST_FORMAT_RIGHT, 90)
+ mainSizer.Add(self.listCtrl, 1, wx.EXPAND | wx.ALL, 5)
+
+ self.emptyLabel = wx.StaticText(self, wx.ID_ANY, _t('No ammo in cargo usable by fitted weapons.'))
+ self.emptyLabel.Hide()
+ mainSizer.Add(self.emptyLabel, 0, wx.ALL, 10)
+
+ btnSizer = wx.BoxSizer(wx.HORIZONTAL)
+ self.exportBtn = wx.Button(self, wx.ID_ANY, _t('Export…'))
+ self.exportBtn.Bind(wx.EVT_BUTTON, self.OnExport)
+ btnSizer.Add(self.exportBtn, 0, wx.RIGHT, 5)
+ self.copyBtn = wx.Button(self, wx.ID_ANY, _t('Copy to clipboard'))
+ self.copyBtn.Bind(wx.EVT_BUTTON, self.OnCopyToClipboard)
+ btnSizer.Add(self.copyBtn, 0)
+ mainSizer.Add(btnSizer, 0, wx.ALL, 5)
+
+ self.SetSizer(mainSizer)
+
+ self.mainFrame.Bind(GE.FIT_CHANGED, self.OnFitChanged)
+ self.Bind(wx.EVT_CLOSE, self.OnClose)
+
+ self.refresh()
+
+ def _get_fit(self):
+ fitID = self.mainFrame.getActiveFit()
+ if fitID is None:
+ return None
+ return Fit.getInstance().getFit(fitID)
+
+ def refresh(self):
+ fit = self._get_fit()
+ self._data = get_ammo_breakdown(fit) if fit else []
+ self.listCtrl.DeleteAllItems()
+ if not self._data:
+ self.listCtrl.Hide()
+ self.emptyLabel.Show()
+ self.exportBtn.Enable(False)
+ self.copyBtn.Enable(False)
+ else:
+ self.emptyLabel.Hide()
+ self.listCtrl.Show()
+ for row in self._data:
+ idx = self.listCtrl.InsertItem(self.listCtrl.GetItemCount(), row['ammoName'])
+ self.listCtrl.SetItem(idx, COL_DAMAGE_TYPE, row['damageType'])
+ self.listCtrl.SetItem(idx, COL_OPTIMAL, row['optimal'])
+ self.listCtrl.SetItem(idx, COL_FALLOFF, row['falloff'])
+ self.listCtrl.SetItem(idx, COL_ALPHA, '{:.1f}'.format(row['alpha']))
+ self.listCtrl.SetItem(idx, COL_DPS, '{:.1f}'.format(row['dps']))
+ self.exportBtn.Enable(True)
+ self.copyBtn.Enable(True)
+ self.Layout()
+
+ def OnFitChanged(self, event):
+ event.Skip()
+ self.refresh()
+
+ def OnClose(self, event):
+ self.mainFrame.Unbind(GE.FIT_CHANGED, handler=self.OnFitChanged)
+ event.Skip()
+
+ def _get_csv_content(self):
+ lines = []
+ lines.append([_t('Ammo Name'), _t('Damage Type'), _t('Optimal'), _t('Falloff'), _t('Alpha'), _t('DPS')])
+ for row in self._data:
+ lines.append([
+ row['ammoName'],
+ row['damageType'],
+ row['optimal'],
+ row['falloff'],
+ '{:.1f}'.format(row['alpha']),
+ '{:.1f}'.format(row['dps']),
+ ])
+ return lines
+
+ def OnExport(self, event):
+ if not self._data:
+ return
+ fit = self._get_fit()
+ defaultFile = 'ammo_breakdown.csv'
+ if fit and fit.ship and fit.ship.item:
+ defaultFile = '{} - ammo_breakdown.csv'.format(fit.ship.item.name.replace('/', '-'))
+ with wx.FileDialog(
+ self, _t('Export ammo breakdown'), '', defaultFile,
+ _t('CSV files') + ' (*.csv)|*.csv', wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT
+ ) as dlg:
+ if dlg.ShowModal() != wx.ID_OK:
+ return
+ path = dlg.GetPath()
+ with open(path, 'w', newline='', encoding='utf-8') as f:
+ writer = csv.writer(f, delimiter=',')
+ for line in self._get_csv_content():
+ writer.writerow(line)
+ event.Skip()
+
+ def OnCopyToClipboard(self, event):
+ if not self._data:
+ return
+ lines = self._get_csv_content()
+ text = '\n'.join(','.join(str(c) for c in row) for row in lines)
+ if wx.TheClipboard.Open():
+ wx.TheClipboard.SetData(wx.TextDataObject(text))
+ wx.TheClipboard.Close()
+ event.Skip()
diff --git a/gui/mainFrame.py b/gui/mainFrame.py
index 44bfc5c496..012eeb4920 100644
--- a/gui/mainFrame.py
+++ b/gui/mainFrame.py
@@ -38,6 +38,7 @@
from eos.config import gamedata_date, gamedata_version
from eos.modifiedAttributeDict import ModifiedAttributeDict
from graphs import GraphFrame
+from gui.ammoBreakdown import AmmoBreakdownFrame
from gui.additionsPane import AdditionsPane
from gui.bitmap_loader import BitmapLoader
from gui.builtinMarketBrowser.events import ItemSelected
@@ -434,6 +435,9 @@ def ShowAboutBox(self, evt):
def OnShowGraphFrame(self, event):
GraphFrame.openOne(self)
+ def OnShowAmmoBreakdownFrame(self, event):
+ AmmoBreakdownFrame.openOne(self)
+
def OnShowGraphFrameHidden(self, event):
GraphFrame.openOne(self, includeHidden=True)
@@ -566,6 +570,7 @@ def registerMenu(self):
# Graphs
self.Bind(wx.EVT_MENU, self.OnShowGraphFrame, id=menuBar.graphFrameId)
self.Bind(wx.EVT_MENU, self.OnShowGraphFrameHidden, id=self.hiddenGraphsId)
+ self.Bind(wx.EVT_MENU, self.OnShowAmmoBreakdownFrame, id=menuBar.ammoBreakdownFrameId)
toggleSearchBoxId = wx.NewId()
toggleShipMarketId = wx.NewId()
diff --git a/gui/mainMenuBar.py b/gui/mainMenuBar.py
index ef677575dd..d873421b0d 100644
--- a/gui/mainMenuBar.py
+++ b/gui/mainMenuBar.py
@@ -40,6 +40,7 @@ def __init__(self, mainFrame):
self.targetProfileEditorId = wx.NewId()
self.implantSetEditorId = wx.NewId()
self.graphFrameId = wx.NewId()
+ self.ammoBreakdownFrameId = wx.NewId()
self.backupFitsId = wx.NewId()
self.exportSkillsNeededId = wx.NewId()
self.importCharacterId = wx.NewId()
@@ -93,6 +94,8 @@ def __init__(self, mainFrame):
fitMenu.AppendSeparator()
fitMenu.Append(self.optimizeFitPrice, _t("&Optimize Fit Price") + "\tCTRL+D")
+ fitMenu.Append(self.ammoBreakdownFrameId, _t("Ammo Break&down"), _t("Cargo ammo stats and export"))
+ self.Enable(self.ammoBreakdownFrameId, False)
graphFrameItem = wx.MenuItem(fitMenu, self.graphFrameId, _t("&Graphs") + "\tCTRL+G")
graphFrameItem.SetBitmap(BitmapLoader.getBitmap("graphs_small", "gui"))
fitMenu.Append(graphFrameItem)
@@ -191,6 +194,7 @@ def fitChanged(self, event):
self.Enable(self.revertCharId, char.isDirty)
self.Enable(self.toggleIgnoreRestrictionID, enable)
+ self.Enable(self.ammoBreakdownFrameId, enable)
if activeFitID:
sFit = Fit.getInstance()
diff --git a/locale/lang.pot b/locale/lang.pot
index 9ce4055148..de08d9ca2b 100644
--- a/locale/lang.pot
+++ b/locale/lang.pot
@@ -93,6 +93,14 @@ msgstr ""
msgid "&Global"
msgstr ""
+#: gui/mainMenuBar.py:97
+msgid "Ammo Break&down"
+msgstr ""
+
+#: gui/mainMenuBar.py:97
+msgid "Cargo ammo stats and export"
+msgstr ""
+
#: gui/mainMenuBar.py:96
msgid "&Graphs"
msgstr ""
@@ -1756,6 +1764,46 @@ msgstr ""
msgid "Exporting skills needed..."
msgstr ""
+#: gui/ammoBreakdown/frame.py
+msgid "Ammo Breakdown"
+msgstr ""
+
+#: gui/ammoBreakdown/frame.py
+msgid "Ammo Name"
+msgstr ""
+
+#: gui/ammoBreakdown/frame.py
+msgid "Optimal"
+msgstr ""
+
+#: gui/ammoBreakdown/frame.py
+msgid "Falloff"
+msgstr ""
+
+#: gui/ammoBreakdown/frame.py
+msgid "Alpha"
+msgstr ""
+
+#: gui/ammoBreakdown/frame.py
+msgid "DPS"
+msgstr ""
+
+#: gui/ammoBreakdown/frame.py
+msgid "No ammo in cargo usable by fitted weapons."
+msgstr ""
+
+#: gui/ammoBreakdown/frame.py
+msgid "Export…"
+msgstr ""
+
+#: gui/ammoBreakdown/frame.py
+msgid "Copy to clipboard"
+msgstr ""
+
+#: gui/ammoBreakdown/frame.py
+msgid "Export ammo breakdown"
+msgstr ""
+
#: gui/builtinPreferenceViews/pyfaGeneralPreferences.py:160
msgid "Extra info in Additions panel tab names"
msgstr ""
diff --git a/service/ammoBreakdown.py b/service/ammoBreakdown.py
new file mode 100644
index 0000000000..8c880d0439
--- /dev/null
+++ b/service/ammoBreakdown.py
@@ -0,0 +1,164 @@
+# =============================================================================
+# Copyright (C) 2010 Diego Duclos
+#
+# This file is part of pyfa.
+#
+# pyfa is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pyfa is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pyfa. If not, see .
+# =============================================================================
+
+import eos.config
+from eos.utils.spoolSupport import SpoolOptions, SpoolType
+from eos.utils.stats import DmgTypes
+from service.fit import Fit
+
+
+def _damage_type_string(volley):
+ """
+ Return primary damage type, or "Primary / Secondary" if secondary > 0.
+ Returns "—" if all four damage types are 0.
+ """
+ ordered = [
+ (volley.em, 'EM'),
+ (volley.thermal, 'Thermal'),
+ (volley.kinetic, 'Kinetic'),
+ (volley.explosive, 'Explosive'),
+ ]
+ ordered.sort(key=lambda x: x[0], reverse=True)
+ primary = ordered[0]
+ if primary[0] <= 0:
+ return "—"
+ secondary = ordered[1]
+ if secondary[0] <= 0:
+ return primary[1]
+ return "{} / {}".format(primary[1], secondary[1])
+
+
+def get_ammo_in_cargo_usable_by_weapons(fit):
+ """
+ Return set of charge items that are in fit.cargo and can be used by at least
+ one turret or launcher on the fit.
+ """
+ if fit is None:
+ return set()
+ cargo_item_ids = {c.itemID for c in fit.cargo if c.item is not None and getattr(c.item, 'isCharge', False)}
+ if not cargo_item_ids:
+ return set()
+ usable = set()
+ for mod in fit.modules:
+ if not mod.canDealDamage():
+ continue
+ try:
+ valid = mod.getValidCharges()
+ except Exception:
+ continue
+ for charge in valid:
+ if charge.ID in cargo_item_ids:
+ usable.add(charge)
+ return usable
+
+
+def get_ammo_breakdown(fit):
+ """
+ For each ammo type in cargo that at least one weapon can use, compute
+ aggregated DPS, Alpha (volley), Optimal, and Falloff assuming all such
+ weapons are loaded with that ammo.
+
+ Returns a list of dicts with keys: ammoName, damageType, optimal, falloff, alpha, dps.
+ optimal/falloff may be strings (e.g. "12.5 – 18.2 km") or "—" for N/A.
+ alpha and dps are floats (total).
+ """
+ if fit is None:
+ return []
+ default_spool = eos.config.settings['globalDefaultSpoolupPercentage'] or 1.0
+ spool_opts = SpoolOptions(SpoolType.SPOOL_SCALE, default_spool, False)
+
+ ammo_items = get_ammo_in_cargo_usable_by_weapons(fit)
+ if not ammo_items:
+ return []
+
+ # Modules that can use each charge (by charge ID)
+ charge_id_to_mods = {}
+ for mod in fit.modules:
+ if not mod.canDealDamage():
+ continue
+ try:
+ valid = mod.getValidCharges()
+ except Exception:
+ continue
+ for charge in valid:
+ if charge in ammo_items:
+ charge_id_to_mods.setdefault(charge.ID, []).append(mod)
+
+ result = []
+ for charge in ammo_items:
+ mods = charge_id_to_mods.get(charge.ID, [])
+ if not mods:
+ continue
+
+ # Save and restore charges
+ saved_charges = [(m, m.charge) for m in mods]
+ try:
+ for m in mods:
+ m.charge = charge
+ fit.calculated = False
+ fit.calculateModifiedAttributes()
+ total_dps = DmgTypes.default()
+ total_volley = DmgTypes.default()
+ optimals = []
+ falloffs = []
+ for m in mods:
+ total_dps += m.getDps(spoolOptions=spool_opts)
+ total_volley += m.getVolley(spoolOptions=spool_opts)
+ try:
+ r = m.maxRange
+ if r is not None:
+ optimals.append(r)
+ except Exception:
+ pass
+ try:
+ f = m.falloff
+ if f is not None:
+ falloffs.append(f)
+ except Exception:
+ pass
+ finally:
+ for m, ch in saved_charges:
+ m.charge = ch
+
+ alpha = total_volley.total
+ dps = total_dps.total
+ if optimals:
+ opt_min, opt_max = min(optimals), max(optimals)
+ optimal_str = "{:.1f} – {:.1f} km".format(opt_min / 1000, opt_max / 1000) if opt_min != opt_max else "{:.1f} km".format(opt_min / 1000)
+ else:
+ optimal_str = "—"
+ if falloffs:
+ f_min, f_max = min(falloffs), max(falloffs)
+ falloff_str = "{:.1f} – {:.1f} km".format(f_min / 1000, f_max / 1000) if f_min != f_max else "{:.1f} km".format(f_min / 1000)
+ else:
+ falloff_str = "—"
+
+ result.append({
+ 'ammoName': charge.name,
+ 'damageType': _damage_type_string(total_volley),
+ 'optimal': optimal_str,
+ 'falloff': falloff_str,
+ 'alpha': alpha,
+ 'dps': dps,
+ })
+ # Sort by ammo name
+ result.sort(key=lambda r: r['ammoName'])
+ if result:
+ Fit.getInstance().recalc(fit)
+ return result
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000000..82371271ea
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,6 @@
+# Load fixtures from _development for use by all tests
+pytest_plugins = [
+ '_development.helpers',
+ '_development.helpers_fits',
+ '_development.helpers_items',
+]
diff --git a/tests/test_modules/test_service/test_ammoBreakdown.py b/tests/test_modules/test_service/test_ammoBreakdown.py
new file mode 100644
index 0000000000..038c92bdfb
--- /dev/null
+++ b/tests/test_modules/test_service/test_ammoBreakdown.py
@@ -0,0 +1,202 @@
+# Add root folder to python paths
+# This must be done on every test in order to pass in Travis
+import os
+import sys
+
+script_dir = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(os.path.realpath(os.path.join(script_dir, '..', '..', '..')))
+sys._called_from_test = True # need db open for tests (see eos/config.py)
+
+# This import is here to hack around circular import issues
+import pytest
+import gui.mainFrame
+# noinspection PyPackageRequirements
+from eos.saveddata.cargo import Cargo
+from service.ammoBreakdown import get_ammo_in_cargo_usable_by_weapons, get_ammo_breakdown
+
+
+# noinspection PyShadowingNames
+@pytest.fixture
+def RifterWithProjectileAmmo():
+ """Rifter fit with 200mm Autocannon IIs and projectile ammo (EMP S, Fusion S) in cargo."""
+ from service.port import Port
+ eft_lines = """[Rifter, Rifter - AC Test]
+200mm Autocannon II, EMP S
+200mm Autocannon II, EMP S
+200mm Autocannon II, EMP S
+
+EMP S x500
+Fusion S x500
+"""
+ fit = Port.importEft(eft_lines.splitlines())
+ assert fit is not None
+ return fit
+
+
+# ---- get_ammo_in_cargo_usable_by_weapons ----
+
+def test_get_ammo_in_cargo_usable_by_weapons_NoneFit():
+ assert get_ammo_in_cargo_usable_by_weapons(None) == set()
+
+
+def test_get_ammo_in_cargo_usable_by_weapons_EmptyCargo():
+ from service.port import Port
+ eft_lines = """[Rifter, Empty Rifter]
+200mm Autocannon II, EMP S
+200mm Autocannon II, EMP S
+200mm Autocannon II, EMP S
+"""
+ fit = Port.importEft(eft_lines.splitlines())
+ assert fit is not None
+ fit.cargo.clear() # Remove any cargo from imported fit
+ assert get_ammo_in_cargo_usable_by_weapons(fit) == set()
+
+
+def test_get_ammo_in_cargo_usable_by_weapons_NoWeapons():
+ from service.port import Port
+ eft_lines = """[Rifter, Rifter No Guns]
+
+EMP S x500
+"""
+ fit = Port.importEft(eft_lines.splitlines())
+ assert fit is not None
+ fit.modules.clear() # Remove any modules
+ assert get_ammo_in_cargo_usable_by_weapons(fit) == set()
+
+
+def test_get_ammo_in_cargo_usable_by_weapons_AmmoNotUsable():
+ from service.port import Port
+ import eos.db
+ eft_lines = """[Rifter, Rifter Wrong Ammo]
+200mm Autocannon II, EMP S
+200mm Autocannon II, EMP S
+200mm Autocannon II, EMP S
+
+Multifrequency S x100
+"""
+ fit = Port.importEft(eft_lines.splitlines())
+ assert fit is not None
+ # Replace cargo with only laser crystal (not usable by projectile guns)
+ fit.cargo.clear()
+ crystal = Cargo(eos.db.getItem("Multifrequency S"))
+ crystal.amount = 100
+ fit.cargo.append(crystal)
+ assert get_ammo_in_cargo_usable_by_weapons(fit) == set()
+
+
+def test_imported_fit_has_modules_and_cargo():
+ """Sanity check: EFT import produces fit with both modules and cargo."""
+ from service.port import Port
+ eft_lines = """[Rifter, Rifter Single Ammo]
+200mm Autocannon II, EMP S
+200mm Autocannon II, EMP S
+200mm Autocannon II, EMP S
+
+EMP S x500
+"""
+ fit = Port.importEft(eft_lines.splitlines())
+ assert fit is not None
+ assert len(fit.modules) >= 1
+ assert len(fit.cargo) >= 1
+
+
+def test_get_ammo_in_cargo_usable_by_weapons_SingleAmmoUsable():
+ from service.port import Port
+ eft_lines = """[Rifter, Rifter Single Ammo]
+200mm Autocannon II, EMP S
+200mm Autocannon II, EMP S
+200mm Autocannon II, EMP S
+
+EMP S x500
+"""
+ fit = Port.importEft(eft_lines.splitlines())
+ assert fit is not None
+ usable = get_ammo_in_cargo_usable_by_weapons(fit)
+ assert len(usable) >= 1
+ names = {c.name for c in usable}
+ assert "EMP S" in names
+
+
+def test_get_ammo_in_cargo_usable_by_weapons_MultipleAmmoUsable(RifterWithProjectileAmmo):
+ fit = RifterWithProjectileAmmo
+ usable = get_ammo_in_cargo_usable_by_weapons(fit)
+ names = {c.name for c in usable}
+ assert "EMP S" in names
+ assert "Fusion S" in names
+ assert len(usable) >= 2
+
+
+# ---- get_ammo_breakdown ----
+
+def test_get_ammo_breakdown_NoneFit():
+ assert get_ammo_breakdown(None) == []
+
+
+def test_get_ammo_breakdown_NoUsableAmmo():
+ from service.port import Port
+ eft_lines = """[Rifter, Rifter No Cargo]
+200mm Autocannon II, EMP S
+200mm Autocannon II, EMP S
+200mm Autocannon II, EMP S
+"""
+ fit = Port.importEft(eft_lines.splitlines())
+ assert fit is not None
+ fit.cargo.clear()
+ assert get_ammo_breakdown(fit) == []
+
+
+def test_get_ammo_breakdown_SingleAmmo():
+ from service.port import Port
+ eft_lines = """[Rifter, Rifter Single Ammo]
+200mm Autocannon II, EMP S
+200mm Autocannon II, EMP S
+200mm Autocannon II, EMP S
+
+EMP S x500
+"""
+ fit = Port.importEft(eft_lines.splitlines())
+ assert fit is not None
+ result = get_ammo_breakdown(fit)
+ assert len(result) == 1
+ row = result[0]
+ assert row['ammoName'] == "EMP S"
+ assert 'damageType' in row
+ assert 'optimal' in row
+ assert 'falloff' in row
+ assert isinstance(row['alpha'], (int, float))
+ assert isinstance(row['dps'], (int, float))
+
+
+def test_get_ammo_breakdown_ResultSortedByName(RifterWithProjectileAmmo):
+ result = get_ammo_breakdown(RifterWithProjectileAmmo)
+ assert len(result) >= 2
+ names = [r['ammoName'] for r in result]
+ assert names == sorted(names)
+
+
+def test_get_ammo_breakdown_ResultStructure(RifterWithProjectileAmmo):
+ result = get_ammo_breakdown(RifterWithProjectileAmmo)
+ assert len(result) >= 1
+ required_keys = {'ammoName', 'damageType', 'optimal', 'falloff', 'alpha', 'dps'}
+ for row in result:
+ assert required_keys.issubset(row.keys())
+ assert isinstance(row['ammoName'], str)
+ assert isinstance(row['damageType'], str)
+ assert isinstance(row['optimal'], str)
+ assert isinstance(row['falloff'], str)
+ assert isinstance(row['alpha'], (int, float))
+ assert isinstance(row['dps'], (int, float))
+
+
+def test_get_ammo_breakdown_DamageTypeFormat(RifterWithProjectileAmmo):
+ result = get_ammo_breakdown(RifterWithProjectileAmmo)
+ for row in result:
+ dt = row['damageType']
+ assert dt in ("EM", "Thermal", "Kinetic", "Explosive", "—") or " / " in dt
+
+
+def test_get_ammo_breakdown_OptimalFalloffFormat(RifterWithProjectileAmmo):
+ result = get_ammo_breakdown(RifterWithProjectileAmmo)
+ for row in result:
+ assert "km" in row['optimal'] or row['optimal'] == "—"
+ assert "km" in row['falloff'] or row['falloff'] == "—"