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'] == "—"