diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..e0a96af --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,61 @@ +# AbyssTracker + +Tracks ISK made running abyssal deadspace and incursions in EVE Online. All amounts are in millions of ISK, all times in minutes. + +## Language + +**Run**: +One trip into abyssal deadspace on one filament, logged with its profit and the minutes it took (20 minus the time left on the timer). An incursion site is logged the same way but is called a site. +_Avoid_: trip, pocket (a pocket is one room inside a run) + +**Session**: +A stretch of play holding the runs of one kind (abyss or incursion), started with a filament (tier and weather) for abyss. Its total is the sum of its runs' profit. +_Avoid_: batch, sitting + +**Active ship**: +The ship EVE says the character is flying at the moment of the read, by its item id. +_Avoid_: current ship, hull + +**Cargo**: +The items directly in the active ship's cargo hold (ESI location flag `Cargo`), ammo included. Not the drone bay, fitted modules, loaded ammo, items inside containers, or other ships and hangars. +_Avoid_: inventory, loot, hold contents + +**Cargo value**: +What the cargo is worth, priced with CCP's average market price (or the adjusted price when there is no average). Typed by hand from EVE's inventory estimated value, or fetched from ESI. +_Avoid_: loot value, net worth + +**Cargo before / Cargo after**: +The cargo value going into a run and coming out of it. Cargo before is prefilled from the previous run's cargo after in the same session, and can be changed when the hold was restocked or unloaded in station. The first run of a session starts with cargo before empty. + +**Run profit**: +Cargo after minus cargo before. Filaments and ammo used up come out of it on their own, because they leave the hold. It can be negative, for example after losing cargo. Old runs logged from the wallet keep the profit they were logged with. +_Avoid_: loot, gain, wallet change + +**Unpriced item**: +A cargo item type with no market price (for example an abyssal module). It is left out of a fetched cargo value and counted ("· 2 unpriced"), so the user knows the figure is incomplete. + +**Wallet**: +The character's ISK balance. Shown on the Abyss page read-only, for reference. It takes no part in run profit any more; the Incursion page still reads payouts from the wallet journal, since those arrive as ISK. +_Avoid_: balance (in UI copy), ISK (as a noun for the wallet) + +## Relationships + +- A **Session** holds many **Runs**; a **Run** belongs to exactly one **Session**. +- A **Run** is either a cargo run (cargo before and cargo after set, no wallet after) or an old wallet run (wallet after set, no cargo values). +- **Run profit** = **Cargo after** − **Cargo before** for a cargo run. +- A run's **Cargo before** defaults to the previous cargo run's **Cargo after** in the same **Session**. +- **Cargo value** counts only the **Active ship**'s **Cargo**; **Unpriced items** are counted, not valued. + +## Example dialogue + +> **Dev:** "The user sold nothing this session, so the **Wallet** barely moved. What did run 3 make?" +> **Domain expert:** "Its **Run profit**: the **Cargo after** they typed when they left, minus the **Cargo before** going in. The **Wallet** doesn't come into it." +> **Dev:** "And if they dropped loot in station before run 4?" +> **Domain expert:** "They change run 4's prefilled **Cargo before** to what's left in the hold. Run 3 keeps its profit." + +## Flagged ambiguities + +- "Loot" was used for both the items picked up in a run and the all-assets "Loot (unsold)" stat from #41. Resolved: the stat is removed, and what a run brings back is measured as **Cargo value**. +- "Wallet" was used for both the ISK balance and a run's starting figure. Resolved: **Wallet** means only the ISK balance; a run starts from **Cargo before**. + +_Rebuilt from the decisions recorded in GitHub issue #38 (grilled 2026-10-25); the original working-tree copy was lost before it was committed._ diff --git a/EveProfitManager/abyss.py b/EveProfitManager/abyss.py index 5cfbb3f..8014f23 100644 --- a/EveProfitManager/abyss.py +++ b/EveProfitManager/abyss.py @@ -16,13 +16,11 @@ def abyssMain(tracker=None): tracker = tracker or Tracker() print(title) weather, tier = askFilament(tracker) - startingIsk = startingInven() - session_id = tracker.start_session(ABYSS, startingIsk, weather, tier) + session_id = tracker.start_session(ABYSS, weather=weather, tier=tier) try: while True: - after_inv = afterInven() - run = tracker.log_abyss_run(session_id, startingIsk, after_inv, timeLeft()) - startingIsk = after_inv + before = cargoBefore(tracker.latest_cargo_after(session_id)) + run = tracker.log_cargo_run(session_id, before, cargoAfter(), timeLeft()) printRun(run) while True: @@ -31,7 +29,6 @@ def abyssMain(tracker=None): if user_input == "1": print(title) - print(f"\n{Fore.GREEN}Last Total: {Fore.RED}{startingIsk} Million\n") break # Exit the inner loop to continue tracking another Abyssal run elif user_input == "0": tracker.end_session(session_id) @@ -61,7 +58,7 @@ def printRun(run): """ print( f"\n{Fore.GREEN}Profit: {Fore.RED}{run.profit} Million" - f"\n{Fore.GREEN}New total: {Fore.RED}{run.isk_after}" + f"\n{Fore.GREEN}Cargo after: {Fore.RED}{run.cargo_after} Million" f"\n{Fore.GREEN}Time inside Abyss: {Fore.RED}{run.minutes} Mins" f"\n{Fore.GREEN}ISK per Hour: {Fore.RED}{isk_per_hour(run.profit, run.minutes):.1f} Million\n" ) @@ -85,24 +82,28 @@ def askFilament(tracker): return WEATHERS[weather_index], tier -def startingInven(): +def cargoBefore(default): """ - Get the ISK total before an Abyssal run. + Get the cargo value going into an Abyssal run. + + Args: + default (float | None): Last run's cargo after, taken on Enter. None on a session's first run. Returns: - float: ISK total before Abyssal run. + float: Cargo value before the run, in millions. """ - return askNumber("\nIsk Total before Abyss") + print() + return askNumber("Cargo before", minimum=0, default=default) -def afterInven(): +def cargoAfter(): """ - Get the ISK total after an Abyssal run. + Get the cargo value coming out of an Abyssal run. Returns: - float: ISK total after Abyssal run. + float: Cargo value after the run, in millions. """ - return askNumber("Isk Total after Abyss") + return askNumber("Cargo after", minimum=0) def timeLeft(): diff --git a/EveProfitManager/cargo.py b/EveProfitManager/cargo.py new file mode 100644 index 0000000..4c583e9 --- /dev/null +++ b/EveProfitManager/cargo.py @@ -0,0 +1,41 @@ +"""Read what the active ship's cargo hold is worth from ESI, in the app's millions of ISK.""" +from dataclasses import dataclass + +from loot import _items, _whole, get_prices + + +@dataclass(frozen=True) +class Cargo: + millions: float + unpriced: int # item types with no market price, left out of `millions` + fetched_at: float # when ESI last answered for the asset list; it caches it for an hour + + +def get_cargo(client, character_id): + """ + The cargo value of the ship EVE says the character is flying now. + + Counts only items directly in that ship with flag Cargo (ammo included), not + its drone bay, fitted modules, other ships, hangars or containers. Blueprint + copies are left out, as in the asset reader, because their type's price is + the original's. + """ + ship = client.get(f"/characters/{character_id}/ship") + if not isinstance(ship, dict) or not _whole(ship.get("ship_item_id")): + raise ValueError(f"Unexpected ship response: {ship!r}") + path = f"/characters/{character_id}/assets" + first = f"{path}?page=1" + items = _items(client.get(first)) + for page in range(2, client.pages(first) + 1): + items += _items(client.get(f"{path}?page={page}")) + quantities = {} + for entry in items: + if not _whole(entry.get("location_id")) or not isinstance(entry.get("location_flag"), str): + raise ValueError(f"Unexpected asset: {entry!r}") + if entry["location_id"] == ship["ship_item_id"] and entry["location_flag"] == "Cargo" \ + and not entry.get("is_blueprint_copy"): + quantities[entry["type_id"]] = quantities.get(entry["type_id"], 0) + entry["quantity"] + prices = get_prices(client) + isk = sum(count * prices[type_id] for type_id, count in quantities.items() if type_id in prices) + unpriced = sum(1 for type_id in quantities if type_id not in prices) + return Cargo(millions=isk / 1e6, unpriced=unpriced, fetched_at=client.fetched_at(first)) diff --git a/EveProfitManager/core.py b/EveProfitManager/core.py index bc7f278..7b32e48 100644 --- a/EveProfitManager/core.py +++ b/EveProfitManager/core.py @@ -40,7 +40,9 @@ profit REAL NOT NULL, minutes REAL NOT NULL, isk_after REAL, - count INTEGER NOT NULL DEFAULT 1 + count INTEGER NOT NULL DEFAULT 1, + cargo_before REAL, + cargo_after REAL ); CREATE TABLE IF NOT EXISTS payouts ( journal_id INTEGER PRIMARY KEY @@ -108,8 +110,10 @@ class Run: recorded_at: datetime profit: float minutes: float - isk_after: float | None + isk_after: float | None # wallet after an old wallet-based run count: int + cargo_before: float | None = None # set, with cargo_after, on a cargo run + cargo_after: float | None = None @dataclass @@ -183,6 +187,10 @@ def _migrate(self): for column, type_ in (("import_key", "TEXT"), ("weather", "TEXT"), ("tier", "INTEGER")): if column not in columns: self.db.execute(f"ALTER TABLE sessions ADD COLUMN {column} {type_}") + columns = [r["name"] for r in self.db.execute("PRAGMA table_info(runs)")] + for column in ("cargo_before", "cargo_after"): # added, never back-filled (ADR 0001) + if column not in columns: + self.db.execute(f"ALTER TABLE runs ADD COLUMN {column} REAL") def close(self): self.db.close() @@ -195,7 +203,8 @@ def start_session(self, kind, starting_isk=None, weather=None, tier=None): Args: kind (str): ABYSS or INCURSION. - starting_isk (float, optional): Wallet before the first abyssal run. + starting_isk (float, optional): Wallet before the first run, kept for old + wallet-based sessions. New abyss sessions start without one. weather (str, optional): Abyssal weather from WEATHERS. tier (int, optional): Filament tier, 0 (Tranquil) to 6 (Cataclysmic). @@ -263,9 +272,49 @@ def delete_session(self, session_id): # --- runs ----------------------------------------------------------- + def log_cargo_run(self, session_id, cargo_before, cargo_after, time_left): + """ + Record an abyssal run from the cargo value before and after it, and the timer. + + Profit is cargo after minus cargo before, so it can be negative (lost cargo). + + Args: + session_id (int): Session to add the run to. + cargo_before (float): Cargo value going in. + cargo_after (float): Cargo value coming out. + time_left (float): Minutes left on the abyssal timer when leaving. + + Returns: + Run: The recorded run. + """ + _require_finite(cargo_before, cargo_after) + _require_time_left(time_left) + with self.db: + cur = self.db.execute( + "INSERT INTO runs (session_id, recorded_at, profit, minutes, cargo_before, cargo_after) " + "VALUES (?, ?, ?, ?, ?, ?)", + (session_id, _now(), cargo_after - cargo_before, MAXTIME - time_left, cargo_before, cargo_after), + ) + return self.run(cur.lastrowid) + + def latest_cargo_after(self, session_id): + """ + Cargo after of the session's latest cargo run, to prefill the next run's cargo before. + + Returns: + float | None: None for a session with no cargo runs (new, or wallet runs only). + """ + row = self.db.execute( + "SELECT cargo_after FROM runs WHERE session_id = ? AND cargo_after IS NOT NULL " + "ORDER BY id DESC LIMIT 1", + (session_id,), + ).fetchone() + return row["cargo_after"] if row else None + def log_abyss_run(self, session_id, isk_before, isk_after, time_left): """ - Record an abyssal run from wallet amounts and the timer. + Record an abyssal run from wallet amounts and the timer, the way runs were logged + before cargo runs. Nothing logs new runs this way now; kept for old wallet-based data. Args: session_id (int): Session to add the run to. @@ -394,6 +443,32 @@ def edit_abyss_run(self, run_id, isk_after, time_left): ) return self.run(run_id) + def edit_cargo_run(self, run_id, cargo_before, cargo_after, time_left): + """ + Correct a cargo run's cargo values or timer. Its profit stands on its own + cargo values, so no other run changes. + + Args: + run_id (int): Cargo run to change. + cargo_before (float): Corrected cargo value going in. + cargo_after (float): Corrected cargo value coming out. + time_left (float): Corrected minutes left on the abyssal timer. + + Returns: + Run: The updated run. + """ + _require_finite(cargo_before, cargo_after) + _require_time_left(time_left) + run = self.run(run_id) + if run is None or run.cargo_after is None: + raise ValueError("Only cargo runs have cargo values to correct") + with self.db: + self.db.execute( + "UPDATE runs SET profit = ?, minutes = ?, cargo_before = ?, cargo_after = ? WHERE id = ?", + (cargo_after - cargo_before, MAXTIME - time_left, cargo_before, cargo_after, run_id), + ) + return self.run(run_id) + def edit_incursion_site(self, run_id, profit, minutes): """Correct an incursion site's payout or time.""" _require_finite(profit) @@ -518,7 +593,8 @@ def export_csv(self, path, kind=None): rows = self.db.execute( "SELECT s.id AS session_id, s.kind, s.started_at, s.ended_at, s.weather, s.tier, s.imported, " "r.id AS run_id, r.recorded_at, r.profit AS profit_millions, r.minutes, " - "r.isk_after AS isk_after_millions, r.count AS runs " + "r.isk_after AS isk_after_millions, r.count AS runs, " + "r.cargo_before AS cargo_before_millions, r.cargo_after AS cargo_after_millions " f"FROM sessions s LEFT JOIN runs r ON r.session_id = s.id {where} ORDER BY s.started_at, s.id, r.id", args, ).fetchall() @@ -674,6 +750,7 @@ def _now(): _CSV_COLUMNS = ( "session_id", "kind", "started_at", "ended_at", "weather", "tier", "imported", "run_id", "recorded_at", "profit_millions", "minutes", "isk_after_millions", "runs", + "cargo_before_millions", "cargo_after_millions", # last, so older columns keep their place ) _SESSION_QUERY = ( @@ -731,4 +808,6 @@ def _run(row): minutes=row["minutes"], isk_after=row["isk_after"], count=row["count"], + cargo_before=row["cargo_before"], + cargo_after=row["cargo_after"], ) diff --git a/EveProfitManager/globals.py b/EveProfitManager/globals.py index 1695adf..4d5ff41 100644 --- a/EveProfitManager/globals.py +++ b/EveProfitManager/globals.py @@ -9,7 +9,7 @@ def clear(): os.system("cls" if os.name == "nt" else "clear") -def askNumber(prompt, minimum=None, maximum=None): +def askNumber(prompt, minimum=None, maximum=None, default=None): """ Keep asking until the user enters a valid number. @@ -17,13 +17,18 @@ def askNumber(prompt, minimum=None, maximum=None): prompt (str): Text shown before the input. minimum (float, optional): Lowest accepted value. maximum (float, optional): Highest accepted value. + default (float, optional): Value returned when the user just presses Enter. Returns: float: The number entered. """ + hint = f" [{default:.15g}]" if default is not None else "" while True: + answer = input(f"{Fore.GREEN}{prompt}{hint}: {Fore.RED}").strip() + if not answer and default is not None: + return default try: - value = float(input(f"{Fore.GREEN}{prompt}: {Fore.RED}")) + value = float(answer) except ValueError: value = math.nan if not math.isfinite(value): diff --git a/EveProfitManager/gui.py b/EveProfitManager/gui.py index 0d5dfb3..9699b54 100644 --- a/EveProfitManager/gui.py +++ b/EveProfitManager/gui.py @@ -31,6 +31,7 @@ from clock import read_clock from journal import get_incursion_payouts from location import AbyssWatcher, get_location +from cargo import get_cargo from wallet import get_wallet from account import SIGN_IN_ERRORS, Account from theme import NEUTRAL_THEME, asset_path, load_fonts, theme_for, ui_family @@ -621,6 +622,7 @@ def build_start_form(self, frame): ... def build_log_form(self, frame): ... def build_timer(self, master): ... def entries(self): return [] + def first_entry(self): return self.entries()[0] # where the cursor goes for the next run def stat_labels(self): return [] def start_args(self): return {} def log_run(self): ... @@ -686,7 +688,7 @@ def start_session(self): self.start_message.configure(text="") self.session_id = self.tracker.start_session(self.kind, **args) self.app.refresh() - self.entries()[0].focus_set() + self.first_entry().focus_set() def end_session(self): session = self.tracker.session(self.session_id) @@ -712,7 +714,7 @@ def log(self): self.flash(str(err), CRITICAL_TEXT) return self.flash(logged_text(run), profit_color(run.profit)) - self.entries()[0].focus_set() + self.first_entry().focus_set() def submit(self): """Log the run or site in the form and return it; raises ValueError for a bad value.""" @@ -845,11 +847,12 @@ def show_tiles(self, runs): class AbyssPage(SessionPage): kind = ABYSS subtitle = ( - "Enter your wallet and pick the filament before the first run. After each run, enter the new " - "wallet total and the time left on the 20-minute timer, or run the built-in timer and leave the time blank." + "Pick the filament before the first run. After each run, enter the value of your cargo hold " + "before and after it, and the time left on the 20-minute timer, or run the built-in timer and " + "leave the time blank." ) unit = "run" - pocket_label = "Wallet after run (M)" + pocket_label = "Cargo after (M)" LOCATION_POLL_MS = 5000 # ESI caches the location for 5 s; the client never asks sooner LOCATION_RETRY_MS = 30000 # after a failed read @@ -898,19 +901,23 @@ def start_session(self): self._wallet = None self._wallet_note = "reading wallet..." super().start_session() + self.prefill_cargo_before() self.read_wallet() def resume(self): super().resume() + self.prefill_cargo_before() self.read_wallet() + def prefill_cargo_before(self): + """Cargo before from the session's latest cargo run, or empty when it has none.""" + latest = self.tracker.latest_cargo_after(self.session_id) if self.session_id else None + set_entry(self.cargo_before, "" if latest is None else f"{latest:.15g}") + def build_start_form(self, frame): - self.start_isk = self.field(frame, 0, "Wallet now (million ISK)", 220, "e.g. 1,250 or 1.25b") - self.start_isk.bind("", lambda _e: self.start_session()) - self.start_wallet_note = self.wallet_fetch(frame, 2, self.start_isk) weather, tier = self.tracker.last_filament() self.tier_labels = [tier_label(t) for t in range(len(TIERS))] - self.tier = self.choice(frame, 1, "Tier", self.tier_labels, self.tier_labels[tier or 0], 170) + self.tier = self.choice(frame, 0, "Tier", self.tier_labels, self.tier_labels[tier or 0], 170) self.paint(ctk.CTkLabel(frame, text="Weather", font=font(12)), text_color="soft").grid( row=2, column=0, columnspan=2, sticky="w", pady=(16, 0) ) @@ -920,46 +927,45 @@ def build_start_form(self, frame): self.painters.append(self.weather.set_theme) def build_log_form(self, frame): - self.after_isk = self.field(frame, 0, "Wallet after run (M)", 180, "e.g. 1,281") - self.time_left = self.field(frame, 1, "Time left (min)", 120, "blank = timer") - self.log_wallet_note = self.wallet_fetch(frame, 3, self.after_isk) - - def wallet_fetch(self, frame, column, entry): - """Add a 'Fetch from EVE' button that fills the wallet entry; returns its status label.""" - note = self.paint(ctk.CTkLabel(frame, text="", font=font(11), justify="left", wraplength=190), text_color="soft") - note.grid(row=0, column=column, sticky="w") - self.paint( - ctk.CTkButton(frame, text="Fetch from EVE", width=130, height=36, fg_color="transparent", - border_width=1, text_color=INK, command=lambda: self.fetch_wallet(entry, note)), - border_color="line", hover_color="panel", - ).grid(row=1, column=column, sticky="w", pady=(2, 0)) - return note - - def fetch_wallet(self, entry, note): - """Read the wallet from ESI into `entry`. The balance is cached by ESI for about two minutes.""" - def show(text, color=None): - note.configure(text=text, text_color=color or self.theme.soft) - - try: - client = self.app.account.client() - except SIGN_IN_ERRORS as e: - show(str(e), CRITICAL_TEXT) - return + self.cargo_before = self.field(frame, 0, "Cargo before (M)", 150, "e.g. 120") + self.cargo_after = self.field(frame, 1, "Cargo after (M)", 150, "e.g. 164.5") + self.time_left = self.field(frame, 2, "Time left (min)", 120, "blank = timer") + self.fetch_buttons = {} + for column, which in enumerate(("before", "after")): + button = self.paint( + ctk.CTkButton(frame, text="Fetch from EVE", width=150, height=28, font=font(12), + border_width=1, command=lambda w=which: self.fetch_cargo(w)), + fg_color=lambda _t: "transparent", border_color="line", hover_color="line", text_color="soft", + ) + button.grid(row=2, column=column, sticky="w", padx=(0, 12), pady=(6, 0)) + self.fetch_buttons[which] = button + note = self.paint(ctk.CTkLabel(frame, text="", font=font(11), wraplength=150, justify="left"), + text_color="soft") + note.grid(row=3, column=column, sticky="nw", padx=(0, 12)) + setattr(self, f"cargo_{which}_note", note) + + def say_cargo_note(self, which, text, problem=False): + note = getattr(self, f"cargo_{which}_note") + note.configure(text=text, text_color=CRITICAL_TEXT if problem else self.theme.soft) + + def fetch_cargo(self, which): + """Fill Cargo before or after with the active ship's cargo value from ESI, off the UI thread.""" + client, why = self.signed_in_client() if client is None: - show("Sign in under Settings first.", CRITICAL_TEXT) + self.say_cargo_note(which, why, problem=True) return - show("Fetching...") + self.say_cargo_note(which, "reading cargo...") - def done(wallet, error): + def done(cargo, error): if error is not None: - show(str(error), CRITICAL_TEXT) + self.say_cargo_note(which, str(error), problem=True) return - entry.delete(0, "end") - entry.insert(0, f"{wallet.millions:.2f}") - stamp = time.strftime("%H:%M:%S", time.localtime(wallet.fetched_at)) - show(f"From EVE at {stamp}. ESI refreshes about every 2 min; edit to override.") + set_entry(getattr(self, f"cargo_{which}"), f"{cargo.millions:.2f}") + stamp = f"From EVE at {datetime.fromtimestamp(cargo.fetched_at):%H:%M}" + unpriced = f" · {cargo.unpriced} unpriced" if cargo.unpriced else "" + self.say_cargo_note(which, f"{stamp}{unpriced}. ESI can lag up to an hour; edit if it's off.") - self.app.run_async(lambda: get_wallet(client, client.character.id), done) + self.app.run_async(lambda: get_cargo(client, client.character.id), done) def reset_watcher(self): """Detection switched on: the next reading is a fresh baseline.""" @@ -981,8 +987,8 @@ def see_abyss(self, in_abyss): if self.timer.running: self.timer.toggle() left = self.timer.face().text - self.notify(f"Left the abyss with {left} left. Enter your wallet and log the run.", self.theme.accent) - self.after_isk.focus_set() + self.notify(f"Left the abyss with {left} left. Enter your cargo after and log the run.", self.theme.accent) + self.cargo_after.focus_set() def build_timer(self, master): return TimerPanel(master, countdown_minutes=MAXTIME) @@ -991,10 +997,13 @@ def idle_theme(self): return theme_for(ABYSS, self.weather.get()) def entries(self): - return [self.after_isk, self.time_left] + return [self.cargo_before, self.cargo_after, self.time_left] + + def first_entry(self): + return self.cargo_after if self.cargo_before.get().strip() else self.cargo_before def pocket_fields(self): - return self.after_isk, self.time_left + return self.cargo_after, self.time_left def stat_labels(self): return [("profit", "Session"), ("rate", "ISK/hr"), ("time", "Time"), @@ -1011,28 +1020,40 @@ def lifetime_totals(self, session): def start_args(self): return { - "starting_isk": parse_isk(self.start_isk.get(), "your wallet"), "weather": self.weather.get(), "tier": self.tier_labels.index(self.tier.get()), } def log_run(self): - after = parse_isk(self.after_isk.get(), "wallet after") + before = parse_isk(self.cargo_before.get(), "cargo before") + after = parse_isk(self.cargo_after.get(), "cargo after") left = self.timer_or(self.time_left, "time left") - before = self.tracker.current_isk(self.session_id) - return self.tracker.log_abyss_run(self.session_id, before, after, left) + return self.tracker.log_cargo_run(self.session_id, before, after, left) def after_log(self): - self.after_isk.delete(0, "end") + self.prefill_cargo_before() + for which in ("before", "after"): + self.say_cargo_note(which, "") + self.cargo_after.delete(0, "end") self.time_left.delete(0, "end") self.read_wallet() def edit_fields(self, run): - return [("Wallet after run (M)", f"{run.isk_after:g}"), ("Time left (min)", f"{MAXTIME - run.minutes:g}")] - - def save_edit(self, run, isk_after, time_left): - return self.tracker.edit_abyss_run( - run.id, parse_isk(isk_after, "wallet after"), parse_minutes(time_left, "time left") + left = ("Time left (min)", f"{MAXTIME - run.minutes:g}") + if run.cargo_after is None: # an old wallet run + return [("Wallet after run (M)", f"{run.isk_after:g}"), left] + return [("Cargo before (M)", f"{run.cargo_before:g}"), ("Cargo after (M)", f"{run.cargo_after:g}"), left] + + def save_edit(self, run, *values): + if run.cargo_after is None: + isk_after, time_left = values + return self.tracker.edit_abyss_run( + run.id, parse_isk(isk_after, "wallet after"), parse_minutes(time_left, "time left") + ) + before, after, time_left = values + return self.tracker.edit_cargo_run( + run.id, parse_isk(before, "cargo before"), parse_isk(after, "cargo after"), + parse_minutes(time_left, "time left"), ) def update_stats(self, session, runs): @@ -1635,7 +1656,8 @@ def __init__(self, master, app): ctk.CTkLabel(inner, text="EVE account", font=font(20, "bold"), text_color=INK).pack(anchor="w") ctk.CTkLabel( inner, font=font(13), text_color=t.soft, justify="left", wraplength=560, - text="Sign in with your EVE character so the app can read your wallet. Your browser opens " + text="Sign in with your EVE character so the app can read your wallet balance, location and " + "incursion payouts. Your browser opens " "for the CCP login; the app never sees your password.", ).pack(anchor="w", pady=(4, 20)) self.active = ctk.CTkLabel(inner, text="", font=font(15), text_color=INK) @@ -2074,7 +2096,7 @@ def set_icon(self): self.iconbitmap(asset_path("icon.ico")) else: # Keep a reference: Tk drops the image once Python garbage-collects it - self.icon = tk.PhotoImage(file=asset_path("icon.png")) + self.icon = tk.PhotoImage(master=self, file=asset_path("icon.png")) self.iconphoto(True, self.icon) def bring_to_front(self): diff --git a/EveProfitManager/test_cargo.py b/EveProfitManager/test_cargo.py new file mode 100644 index 0000000..ac99a92 --- /dev/null +++ b/EveProfitManager/test_cargo.py @@ -0,0 +1,82 @@ +import unittest + +from cargo import Cargo, get_cargo +from test_loot import FakeClient, item + +SHIP = "/characters/123/ship" +ASSETS = "/characters/123/assets" +SHIP_ID = 1_050_000_000_001 + + +def in_hold(type_id, quantity=1, **extra): + return item(type_id, quantity, location_id=SHIP_ID, location_flag="Cargo", location_type="item", **extra) + + +def client(first_page, second_page=None, prices=None, ship=None, fetched_at=1_700_000_000.0): + bodies = { + SHIP: {"ship_item_id": SHIP_ID, "ship_name": "x", "ship_type_id": 17_738} if ship is None else ship, + f"{ASSETS}?page=1": first_page, + "/markets/prices": [{"type_id": 34, "average_price": 5.0}, {"type_id": 47_762, "adjusted_price": 1_000_000.0}] + if prices is None else prices, + } + pages = {} + if second_page is not None: + bodies[f"{ASSETS}?page=2"] = second_page + pages[f"{ASSETS}?page=1"] = 2 + return FakeClient(bodies, pages=pages, fetched_at=fetched_at) + + +class GetCargoTests(unittest.TestCase): + def test_values_the_cargo_in_millions_with_the_time_esi_last_answered(self): + c = client([in_hold(34, 2_000_000), in_hold(47_762, 3)], fetched_at=1_700_000_123.0) + self.assertEqual(get_cargo(c, 123), Cargo(millions=13.0, unpriced=0, fetched_at=1_700_000_123.0)) + + def test_ammo_in_the_cargo_hold_counts(self): + c = client([in_hold(47_762, 100)]) + self.assertEqual(get_cargo(c, 123).millions, 100.0) + + def test_only_the_active_ships_cargo_flag_counts(self): + c = client([ + in_hold(34, 1_000_000), + item(34, 1_000_000, location_id=SHIP_ID, location_flag="DroneBay", location_type="item"), + item(34, 1_000_000, location_id=SHIP_ID, location_flag="HiSlot0", location_type="item"), + item(34, 1_000_000, location_id=SHIP_ID, location_flag="LoSlot0", location_type="item"), + item(34, 1_000_000, location_id=SHIP_ID + 1, location_flag="Cargo", location_type="item"), # another ship + item(34, 1_000_000), # a station hangar + item(34, 1_000_000, location_id=9_000_000_000_001, location_flag="Unlocked", location_type="item"), # a container + ]) + self.assertEqual(get_cargo(c, 123).millions, 5.0) + + def test_follows_the_asset_pages(self): + c = client([in_hold(34, 1_000_000)], [in_hold(34, 1_000_000)]) + self.assertEqual(get_cargo(c, 123).millions, 10.0) + + def test_leaves_out_blueprint_copies(self): + c = client([in_hold(34, 1_000_000), in_hold(47_762, is_blueprint_copy=True)]) + self.assertEqual(get_cargo(c, 123).millions, 5.0) + + def test_counts_unpriced_item_types_and_leaves_them_out_of_the_value(self): + c = client([in_hold(34, 1_000_000), in_hold(52_227), in_hold(52_227), in_hold(52_228, 4)]) + cargo = get_cargo(c, 123) + self.assertEqual((cargo.millions, cargo.unpriced), (5.0, 2)) + + def test_an_empty_hold_is_worth_nothing(self): + self.assertEqual(get_cargo(client([item(34)]), 123).millions, 0.0) + + def test_rejects_malformed_bodies(self): + good = [in_hold(34)] + cases = { + "ship not a dict": client(good, ship=[1]), + "ship without an item id": client(good, ship={"ship_type_id": 1}), + "ship id not whole": client(good, ship={"ship_item_id": "7"}), + "assets not a list": client({"error": "x"}), + "asset without a location": client([{"type_id": 34, "quantity": 1}]), + "prices not a list": client(good, prices={"error": "x"}), + } + for name, c in cases.items(): + with self.subTest(name), self.assertRaises(ValueError): + get_cargo(c, 123) + + +if __name__ == "__main__": + unittest.main() diff --git a/EveProfitManager/test_cargo_fetch.py b/EveProfitManager/test_cargo_fetch.py new file mode 100644 index 0000000..295cc51 --- /dev/null +++ b/EveProfitManager/test_cargo_fetch.py @@ -0,0 +1,123 @@ +import os +import tempfile +import tkinter +import unittest +from datetime import datetime +from types import SimpleNamespace +from unittest import mock + +from cargo import Cargo +from core import ABYSS, Tracker +from theme import load_fonts + +READ_AT = datetime(2026, 10, 25, 18, 40).timestamp() + + +class FetchCargoFromEveTest(unittest.TestCase): + """Drives the two Fetch from EVE buttons through gui.App with the cargo reader mocked.""" + + def setUp(self): + load_fonts() + try: + tkinter.Tk().destroy() + except tkinter.TclError as err: + self.skipTest(f"Tk cannot open a window: {err}") + import customtkinter as ctk + import gui + + self.gui = gui + ctk.set_appearance_mode("dark") + self.client = SimpleNamespace(character=SimpleNamespace(id=123)) + account = mock.patch.object(gui.Account, "client", side_effect=lambda: self.client) + account.start() + self.addCleanup(account.stop) + self.cargo = mock.Mock(return_value=Cargo(millions=164.5678, unpriced=0, fetched_at=READ_AT)) + reader = mock.patch.object(gui, "get_cargo", self.cargo) + reader.start() + self.addCleanup(reader.stop) + wallet = mock.patch.object(gui, "get_wallet", side_effect=RuntimeError("no wallet here")) + wallet.start() + self.addCleanup(wallet.stop) + folder = tempfile.TemporaryDirectory() + self.addCleanup(folder.cleanup) + self.tracker = Tracker(os.path.join(folder.name, "tracker.db")) + self.addCleanup(self.tracker.close) + self.tracker.start_session(ABYSS, weather="Gamma", tier=2) + sync = mock.patch.object(gui.App, "run_async", lambda _app, work, done: self.finish(work, done)) + sync.start() + self.addCleanup(sync.stop) + self.app = gui.App(self.tracker) + self.addCleanup(self.app.destroy) + self.app.update() + self.page = self.app.pages["abyss"] + + @staticmethod + def finish(work, done): + try: + result = work() + except Exception as error: + done(None, error) + else: + done(result, None) + + def note(self, which): + return getattr(self.page, f"{which}_note").cget("text") + + def test_each_button_fills_its_own_field_to_two_decimals_and_stamps_when_eve_answered(self): + self.page.fetch_cargo("before") + self.assertEqual(self.page.cargo_before.get(), "164.57") + self.assertEqual(self.page.cargo_after.get(), "") + self.assertIn("From EVE at 18:40", self.note("cargo_before")) + self.assertEqual(self.note("cargo_after"), "") + self.page.fetch_cargo("after") + self.assertEqual(self.page.cargo_after.get(), "164.57") + self.assertIn("From EVE at 18:40", self.note("cargo_after")) + self.assertIn("up to an hour", self.note("cargo_after")) + self.assertIn("edit", self.note("cargo_after")) + + def test_the_buttons_are_on_the_page_and_call_fetch(self): + self.page.fetch_buttons["after"].invoke() + self.assertEqual(self.page.cargo_after.get(), "164.57") + self.page.fetch_buttons["before"].invoke() + self.assertEqual(self.page.cargo_before.get(), "164.57") + + def test_names_the_unpriced_item_types(self): + self.cargo.return_value = Cargo(millions=10.0, unpriced=2, fetched_at=READ_AT) + self.page.fetch_cargo("after") + self.assertIn("From EVE at 18:40 · 2 unpriced", self.note("cargo_after")) + + def test_an_error_shows_in_the_note_in_the_critical_colour_and_leaves_the_field_alone(self): + self.gui.set_entry(self.page.cargo_after, "88") + self.cargo.side_effect = ValueError("Unexpected ship response") + self.page.fetch_cargo("after") + self.assertEqual(self.page.cargo_after.get(), "88") + self.assertIn("Unexpected ship response", self.note("cargo_after")) + self.assertEqual(self.page.cargo_after_note.cget("text_color"), self.gui.CRITICAL_TEXT) + + def test_a_fetch_after_an_error_clears_the_error_colour(self): + self.cargo.side_effect = ValueError("x") + self.page.fetch_cargo("after") + self.cargo.side_effect = None + self.page.fetch_cargo("after") + self.assertNotEqual(self.page.cargo_after_note.cget("text_color"), self.gui.CRITICAL_TEXT) + + def test_signed_out_fetch_says_to_sign_in_and_the_field_still_works_by_hand(self): + self.client = None + self.gui.set_entry(self.page.cargo_before, "10") + self.page.fetch_cargo("before") + self.cargo.assert_not_called() + self.assertIn("Sign in", self.note("cargo_before")) + self.assertEqual(self.page.cargo_before.get(), "10") + self.assertEqual(self.page.cargo_before_note.cget("text_color"), self.gui.CRITICAL_TEXT) + + def test_logging_a_run_clears_the_notes(self): + self.page.fetch_cargo("before") + self.page.fetch_cargo("after") + self.gui.set_entry(self.page.time_left, "5") + self.page.log() + self.assertEqual(self.note("cargo_before"), "") + self.assertEqual(self.note("cargo_after"), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/EveProfitManager/test_cargo_page.py b/EveProfitManager/test_cargo_page.py new file mode 100644 index 0000000..e2da006 --- /dev/null +++ b/EveProfitManager/test_cargo_page.py @@ -0,0 +1,152 @@ +import os +import tempfile +import tkinter +import unittest +from unittest import mock + +from core import ABYSS, Tracker +from theme import load_fonts + + +class AbyssCargoFormTest(unittest.TestCase): + """Drives the Abyss page's start and log forms by hand, signed out, as a user would.""" + + def setUp(self): + load_fonts() # as the app does, before the first Tk: macOS reads the font list only then + try: + tkinter.Tk().destroy() + except tkinter.TclError as err: # no display, e.g. headless Linux CI + self.skipTest(f"Tk cannot open a window: {err}") + import customtkinter as ctk + import gui + + self.gui = gui + ctk.set_appearance_mode("dark") + signed_out = mock.patch.object(gui.Account, "client", return_value=None) + signed_out.start() + self.addCleanup(signed_out.stop) + folder = tempfile.TemporaryDirectory() + self.addCleanup(folder.cleanup) + self.path = os.path.join(folder.name, "tracker.db") + self.tracker = Tracker(self.path) + self.addCleanup(self.tracker.close) + + def open_app(self): + self.app = self.gui.App(self.tracker) + self.addCleanup(self.app.destroy) + self.app.update() + self.page = self.app.pages["abyss"] + return self.page + + def texts(self, widget): + """Text of every label under a widget.""" + found = [] + for child in widget.winfo_children(): + if isinstance(child, self.gui.ctk.CTkLabel): + found.append(child.cget("text")) + found.extend(self.texts(child)) + return found + + def type_run(self, before, after, left): + for entry, text in ((self.page.cargo_before, before), (self.page.cargo_after, after), + (self.page.time_left, left)): + self.gui.set_entry(entry, text) + self.page.log() + + def test_starting_a_session_asks_for_the_filament_only(self): + page = self.open_app() + labels = self.texts(page.start_form) + self.assertEqual(labels[:2], ["Tier", "Weather"]) + self.assertFalse(any("wallet" in text.lower() for text in labels)) + self.assertNotIn("wallet", page.subtitle.lower()) + page.weather.pick("Exotic") + page.start_session() + session = self.tracker.session(page.session_id) + self.assertEqual((session.weather, session.starting_isk), ("Exotic", None)) + self.assertEqual(page.cargo_before.get(), "") + + def test_logs_a_run_from_typed_cargo_and_moves_cargo_after_into_cargo_before(self): + sid = self.tracker.start_session(ABYSS, weather="Gamma", tier=2) + page = self.open_app() + self.assertEqual(self.texts(page.log_form)[:3], ["Cargo before (M)", "Cargo after (M)", "Time left (min)"]) + with mock.patch.object(page.cargo_after, "focus_set") as focus: + self.type_run("120", "1,164.5", "6") + run = self.tracker.runs(sid)[0] + self.assertAlmostEqual(run.profit, 1044.5) + self.assertEqual(run.minutes, 14) + self.assertEqual((page.cargo_before.get(), page.cargo_after.get(), page.time_left.get()), + ("1164.5", "", "")) + focus.assert_called_once() # cargo before is filled in, so the cursor waits in cargo after + self.assertIn("+1.04B", page.message.cget("text")) + + def test_a_loss_is_logged_and_a_bad_value_logs_nothing(self): + sid = self.tracker.start_session(ABYSS, weather="Gamma", tier=2) + page = self.open_app() + self.type_run("200", "lots", "6") + self.assertEqual(self.tracker.runs(sid), []) + self.assertIn("cargo after", page.message.cget("text")) + self.type_run("200", "150", "6") + self.assertEqual(self.tracker.runs(sid)[0].profit, -50) + + def test_resuming_prefills_cargo_before_from_the_latest_cargo_run(self): + sid = self.tracker.start_session(ABYSS, weather="Gamma", tier=2) + self.tracker.log_cargo_run(sid, 10, 1234.5678, time_left=5) + page = self.open_app() + self.assertEqual(page.cargo_before.get(), "1234.5678") + + def test_resuming_a_wallet_only_session_leaves_cargo_before_empty(self): + sid = self.tracker.start_session(ABYSS, starting_isk=1000, weather="Gamma", tier=2) + self.tracker.log_abyss_run(sid, 1000, 1030, time_left=10) + page = self.open_app() + self.assertEqual(page.cargo_before.get(), "") + self.type_run("0", "40", "10") + self.assertEqual([r.profit for r in self.tracker.runs(sid)], [30, 40]) + + def open_edit(self, run): + self.page.edit_run(run.id) + self.app.update() + (dialog,) = [w for w in self.page.winfo_children() if isinstance(w, self.gui.EditRunDialog)] + return dialog + + def test_editing_a_cargo_run_shows_its_cargo_prefilled_and_saving_updates_the_session(self): + sid = self.tracker.start_session(ABYSS, weather="Gamma", tier=2) + first = self.tracker.log_cargo_run(sid, 100, 1310, time_left=5) # meant 131 + last = self.tracker.log_cargo_run(sid, 1310, 1400, time_left=6) + page = self.open_app() + dialog = self.open_edit(first) + self.assertEqual(self.texts(dialog)[:3], ["Cargo before (M)", "Cargo after (M)", "Time left (min)"]) + self.assertEqual([e.get() for e in dialog.entries], ["100", "1310", "5"]) + self.gui.set_entry(dialog.entries[1], "131") + dialog.save() + self.app.update() + edited = self.tracker.run(first.id) + self.assertEqual((edited.cargo_after, edited.profit), (131, 31)) + self.assertEqual(self.tracker.run(last.id), last) + self.assertEqual(page.stats["profit"].value.cget("text"), self.gui.signed_isk(31 + 90)) + + def test_a_bad_cargo_edit_keeps_the_dialog_open_and_changes_nothing(self): + sid = self.tracker.start_session(ABYSS, weather="Gamma", tier=2) + run = self.tracker.log_cargo_run(sid, 100, 131, time_left=5) + self.open_app() + dialog = self.open_edit(run) + self.gui.set_entry(dialog.entries[2], "25") + dialog.save() + self.assertIn("between 0 and 20", dialog.message.cget("text")) + self.assertEqual(self.tracker.run(run.id), run) + + def test_an_old_wallet_run_keeps_the_wallet_edit_dialog(self): + sid = self.tracker.start_session(ABYSS, starting_isk=1000, weather="Gamma", tier=2) + run = self.tracker.log_abyss_run(sid, 1000, 1030, time_left=10) + self.open_app() + dialog = self.open_edit(run) + self.assertEqual(self.texts(dialog)[:2], ["Wallet after run (M)", "Time left (min)"]) + + def test_settings_copy_no_longer_asks_for_the_wallet_for_abyssal_runs(self): + self.open_app() + copy = " ".join(self.texts(self.app.pages["settings"])) + self.assertIn("wallet balance", copy) + self.assertNotIn("enter your wallet", copy.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/EveProfitManager/test_cli_abyss.py b/EveProfitManager/test_cli_abyss.py new file mode 100644 index 0000000..80c2e61 --- /dev/null +++ b/EveProfitManager/test_cli_abyss.py @@ -0,0 +1,89 @@ +import io +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +import abyss +from core import Tracker + + +class CliAbyssTest(unittest.TestCase): + """Drive the terminal Abyss tracker with scripted input against a temp database.""" + + def setUp(self): + self.dir = TemporaryDirectory() + self.tracker = Tracker(Path(self.dir.name) / "test.db") + + def tearDown(self): + self.tracker.close() + self.dir.cleanup() + + def drive(self, answers): + """Run the CLI with these answers. Returns (session, prompts asked, printed output).""" + prompts = [] + answers = iter(answers) + + def fake_input(prompt=""): + prompts.append(prompt) + return next(answers) + + out = io.StringIO() + with patch("builtins.input", fake_input), patch("abyss.clear"), redirect_stdout(out): + session = abyss.abyssMain(self.tracker) + return session, prompts, out.getvalue() + + def test_asks_for_the_filament_and_cargo_not_a_wallet(self): + session, prompts, _ = self.drive(["1", "1", "100", "130", "5", "0"]) + asked = " ".join(prompts).lower() + self.assertNotIn("isk total", asked) + self.assertNotIn("wallet", asked) + self.assertIn("weather", prompts[0].lower()) + self.assertIn("tier", prompts[1].lower()) + self.assertIn("cargo before", prompts[2].lower()) + self.assertIn("cargo after", prompts[3].lower()) + self.assertIn("time left", prompts[4].lower()) + self.assertIsNone(self.tracker.session(session.id).starting_isk) + + def test_first_run_has_no_cargo_before_default(self): + _, prompts, _ = self.drive(["1", "1", "100", "130", "5", "0"]) + self.assertIn("Cargo before: ", prompts[2]) # no " [default]" hint + + def test_logs_a_cargo_run_with_profit_after_minus_before(self): + session, _, out = self.drive(["1", "1", "100", "130", "5", "0"]) + [run] = self.tracker.runs(session.id) + self.assertEqual((run.cargo_before, run.cargo_after), (100, 130)) + self.assertEqual(run.profit, 30) + self.assertEqual(run.minutes, 15) + self.assertIsNone(run.isk_after) + self.assertIn("Cargo after", out) + self.assertNotIn("New total", out) + + def test_cargo_before_defaults_to_the_last_cargo_after_on_enter(self): + session, prompts, _ = self.drive(["1", "1", "100", "130", "5", "1", "", "90", "2", "0"]) + self.assertIn("Cargo before [130]: ", prompts[6]) + first, second = self.tracker.runs(session.id) + self.assertEqual((second.cargo_before, second.cargo_after), (130, 90)) + self.assertEqual(second.profit, -40) + + def test_a_typed_cargo_before_overrides_the_default(self): + session, _, _ = self.drive(["1", "1", "100", "130", "5", "1", "50", "90", "2", "0"]) + self.assertEqual(self.tracker.runs(session.id)[1].cargo_before, 50) + + def test_bad_input_is_asked_again(self): + session, _, out = self.drive( + ["1", "1", "lots", "", "100", "abc", "130", "x", "25", "-1", "5", "0"] + ) + [run] = self.tracker.runs(session.id) + self.assertEqual((run.cargo_before, run.cargo_after, run.minutes), (100, 130, 15)) + self.assertIn("Please enter a number", out) + + def test_the_cli_no_longer_logs_wallet_runs(self): + with patch.object(Tracker, "log_abyss_run", side_effect=AssertionError("wallet run")): + session, _, _ = self.drive(["1", "1", "100", "130", "5", "0"]) + self.assertEqual(session.runs, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/EveProfitManager/test_core.py b/EveProfitManager/test_core.py index f0c0c0a..e381f2d 100644 --- a/EveProfitManager/test_core.py +++ b/EveProfitManager/test_core.py @@ -34,15 +34,64 @@ class TrackerTest(unittest.TestCase): def setUp(self): self.tracker = Tracker(":memory:") - def test_abyss_runs_chain_wallets(self): - sid = self.tracker.start_session(ABYSS, starting_isk=1000) - self.tracker.log_abyss_run(sid, self.tracker.current_isk(sid), 1031, time_left=5) - self.tracker.log_abyss_run(sid, self.tracker.current_isk(sid), 1050, time_left=8) + def test_cargo_runs_chain_through_the_latest_cargo_after(self): + sid = self.tracker.start_session(ABYSS) + self.assertIsNone(self.tracker.latest_cargo_after(sid)) + self.tracker.log_cargo_run(sid, 100, 131, time_left=5) + self.tracker.log_cargo_run(sid, self.tracker.latest_cargo_after(sid), 150, time_left=8) session = self.tracker.session(sid) self.assertEqual(session.profit, 50) self.assertEqual(session.minutes, 27) self.assertEqual(session.runs, 2) - self.assertEqual(self.tracker.current_isk(sid), 1050) + self.assertEqual(self.tracker.latest_cargo_after(sid), 150) + + def test_a_cargo_run_records_both_values_and_no_wallet(self): + sid = self.tracker.start_session(ABYSS) + run = self.tracker.log_cargo_run(sid, 120, 164.5, time_left=6) + self.assertAlmostEqual(run.profit, 44.5) + self.assertEqual(run.minutes, 14) + self.assertEqual((run.cargo_before, run.cargo_after, run.isk_after), (120, 164.5, None)) + self.assertIsNone(self.tracker.session(sid).starting_isk) + + def test_a_cargo_run_can_lose_isk(self): + sid = self.tracker.start_session(ABYSS) + run = self.tracker.log_cargo_run(sid, 300, 40, time_left=0) + self.assertEqual(run.profit, -260) + self.assertEqual(self.tracker.session(sid).profit, -260) + + def test_bad_cargo_runs_are_rejected_with_the_validator_errors(self): + sid = self.tracker.start_session(ABYSS) + inf, nan = float("inf"), float("nan") + for before, after, left, error in ( + (inf, 100, 5, "real numbers"), (100, nan, 5, "real numbers"), (100, "120", 5, "real numbers"), + (100, 120, nan, "real numbers"), (100, 120, 25, "between 0 and 20"), (100, 120, -1, "between 0 and 20"), + ): + with self.assertRaisesRegex(ValueError, error): + self.tracker.log_cargo_run(sid, before, after, left) + self.assertEqual(self.tracker.runs(sid), []) + + def test_latest_cargo_after_skips_wallet_runs_and_other_sessions(self): + old = self.tracker.start_session(ABYSS, starting_isk=1000) + self.tracker.log_abyss_run(old, 1000, 1030, time_left=10) + self.assertIsNone(self.tracker.latest_cargo_after(old)) # wallet runs only + self.tracker.log_cargo_run(old, 10, 70, time_left=10) + self.tracker.log_abyss_run(old, 1030, 1040, time_left=10) # a wallet run after it + self.assertEqual(self.tracker.latest_cargo_after(old), 70) + self.assertIsNone(self.tracker.latest_cargo_after(self.tracker.start_session(ABYSS))) + + def test_a_session_open_across_the_update_mixes_wallet_and_cargo_runs(self): + sid = self.tracker.start_session(ABYSS, starting_isk=1000, weather="Dark", tier=3) + self.tracker.log_abyss_run(sid, 1000, 1030, time_left=10) # logged before the update + self.assertIsNone(self.tracker.latest_cargo_after(sid)) # its first cargo before is empty + self.tracker.log_cargo_run(sid, 50, 90, time_left=12) + self.tracker.log_cargo_run(sid, 90, 80, time_left=5) + self.assertEqual([r.profit for r in self.tracker.runs(sid)], [30, 40, -10]) + session = self.tracker.session(sid) + self.assertEqual((session.profit, session.minutes, session.runs), (60, 33, 3)) + self.assertAlmostEqual(session.isk_per_hour, 60 / (33 / 60)) + self.assertEqual(self.tracker.totals(ABYSS).profit, 60) + self.assertEqual(self.tracker.totals_by_weather()["Dark"].profit, 60) + self.assertEqual(self.tracker.sessions(ABYSS)[0].profit, 60) def test_sessions_do_not_leak_profit(self): for _ in range(2): @@ -117,6 +166,42 @@ def test_bad_abyss_edits_are_rejected(self): self.assertEqual(self.tracker.run(run.id), run) self.assertEqual(self.tracker.run(site.id), site) + def test_editing_a_cargo_run_recomputes_its_profit_and_minutes(self): + sid = self.tracker.start_session(ABYSS) + typo = self.tracker.log_cargo_run(sid, 100, 1310, time_left=5) # meant 131 + edited = self.tracker.edit_cargo_run(typo.id, 90, 131, time_left=8) + self.assertEqual((edited.cargo_before, edited.cargo_after, edited.isk_after), (90, 131, None)) + self.assertEqual((edited.profit, edited.minutes), (41, 12)) + self.assertEqual(self.tracker.session(sid).profit, 41) + + def test_editing_a_cargo_run_leaves_the_runs_around_it_alone(self): + sid = self.tracker.start_session(ABYSS, starting_isk=1000) + wallet = self.tracker.log_abyss_run(sid, 1000, 1030, time_left=10) + first = self.tracker.log_cargo_run(sid, 50, 90, time_left=12) + middle = self.tracker.log_cargo_run(sid, 90, 900, time_left=6) + last = self.tracker.log_cargo_run(sid, 900, 120, time_left=5) + self.tracker.edit_cargo_run(middle.id, 90, 100, time_left=6) + for run in (wallet, first, last): + self.assertEqual(self.tracker.run(run.id), run) + self.assertEqual(self.tracker.latest_cargo_after(sid), 120) + + def test_bad_cargo_edits_are_rejected_like_logging(self): + sid = self.tracker.start_session(ABYSS, starting_isk=1000) + run = self.tracker.log_cargo_run(sid, 100, 131, time_left=5) + wallet = self.tracker.log_abyss_run(sid, 1000, 1030, time_left=10) + site = self.tracker.log_incursion_site(self.tracker.start_session(INCURSION), 31, 10) + inf, nan = float("inf"), float("nan") + for run_id, before, after, left, error in ( + (run.id, inf, 131, 5, "real numbers"), (run.id, 100, nan, 5, "real numbers"), + (run.id, 100, "131", 5, "real numbers"), (run.id, 100, 131, nan, "real numbers"), + (run.id, 100, 131, 25, "between 0 and 20"), (run.id, 100, 131, -1, "between 0 and 20"), + (wallet.id, 1000, 1030, 5, "cargo"), (site.id, 0, 31, 5, "cargo"), (999, 0, 31, 5, "cargo"), + ): + with self.assertRaisesRegex(ValueError, error): + self.tracker.edit_cargo_run(run_id, before, after, left) + for unchanged in (run, wallet, site): + self.assertEqual(self.tracker.run(unchanged.id), unchanged) + def test_editing_incursion_site(self): sid = self.tracker.start_session(INCURSION) self.tracker.log_incursion_site(sid, 31, 10) @@ -357,6 +442,36 @@ def test_database_from_before_filaments_gains_weather_and_tier(self): self.assertEqual(tracker.session(sid).weather, "Dark") tracker.close() + def test_database_from_before_cargo_runs_gains_the_cargo_columns_and_keeps_old_runs(self): + with TemporaryDirectory() as tmp: + db = Path(tmp) / "old.db" + conn = sqlite3.connect(db) + conn.executescript( + "CREATE TABLE sessions (id INTEGER PRIMARY KEY, kind TEXT NOT NULL, started_at TEXT NOT NULL, " + "ended_at TEXT, starting_isk REAL, imported INTEGER NOT NULL DEFAULT 0, import_key TEXT, " + "weather TEXT, tier INTEGER);" + "CREATE TABLE runs (id INTEGER PRIMARY KEY, session_id INTEGER NOT NULL, recorded_at TEXT NOT NULL, " + "profit REAL NOT NULL, minutes REAL NOT NULL, isk_after REAL, count INTEGER NOT NULL DEFAULT 1);" + "INSERT INTO sessions (kind, started_at, starting_isk, weather, tier) " + "VALUES ('abyss', '2026-10-20T10:00:00', 1000, 'Dark', 3);" + "INSERT INTO runs (session_id, recorded_at, profit, minutes, isk_after) " + "VALUES (1, '2026-10-20T10:20:00', 30, 10, 1030);" + ) + conn.commit() + conn.close() + tracker = Tracker(db) + old = tracker.runs(1)[0] + self.assertEqual((old.profit, old.minutes, old.isk_after), (30, 10, 1030)) + self.assertEqual((old.cargo_before, old.cargo_after), (None, None)) + self.assertEqual(tracker.totals(ABYSS).profit, 30) + self.assertIsNone(tracker.latest_cargo_after(1)) + tracker.log_cargo_run(1, 0, 25, time_left=8) # the session left open carries on + self.assertEqual(tracker.session(1).profit, 55) + tracker.close() + tracker = Tracker(db) # and opens again once migrated + self.assertEqual(tracker.latest_cargo_after(1), 25) + tracker.close() + def test_last_filament_is_remembered(self): with TemporaryDirectory() as tmp: db = Path(tmp) / "t.db" diff --git a/EveProfitManager/test_detect.py b/EveProfitManager/test_detect.py index 9c3c747..6508711 100644 --- a/EveProfitManager/test_detect.py +++ b/EveProfitManager/test_detect.py @@ -2,6 +2,7 @@ import tempfile import tkinter import unittest +from unittest import mock from core import ABYSS, Tracker from theme import load_fonts @@ -24,7 +25,7 @@ def setUp(self): self.addCleanup(folder.cleanup) self.tracker = Tracker(os.path.join(folder.name, "tracker.db")) self.addCleanup(self.tracker.close) - self.tracker.start_session(ABYSS, starting_isk=1000, weather="Gamma", tier=2) + self.tracker.start_session(ABYSS, weather="Gamma", tier=2) self.app = gui.App(self.tracker) self.addCleanup(self.app.destroy) self.app.update() @@ -41,17 +42,21 @@ def test_leaving_pauses_the_timer_and_prompts_to_log_the_run(self): self.page.see_abyss(False) self.page.see_abyss(True) self.page.timer.elapsed_before = 7 * 60 # pretend 7 minutes passed - self.page.see_abyss(False) + with mock.patch.object(self.page.cargo_after, "focus_set") as focus: + self.page.see_abyss(False) + focus.assert_called_once() self.assertFalse(self.page.timer.running) self.assertIn("13:00", self.page.message.cget("text")) self.assertIn("log", self.page.message.cget("text").lower()) + self.assertIn("cargo after", self.page.message.cget("text").lower()) def test_the_paused_timer_time_is_what_gets_logged(self): self.page.see_abyss(False) self.page.see_abyss(True) self.page.timer.elapsed_before = 8 * 60 self.page.see_abyss(False) - self.page.after_isk.insert(0, "1,044.5") + self.page.cargo_before.insert(0, "100") + self.page.cargo_after.insert(0, "144.5") self.page.log() run = self.tracker.runs(self.page.session_id)[0] self.assertEqual(run.minutes, 8) diff --git a/EveProfitManager/test_export.py b/EveProfitManager/test_export.py index eb65223..bac1b2b 100644 --- a/EveProfitManager/test_export.py +++ b/EveProfitManager/test_export.py @@ -47,6 +47,30 @@ def test_csv_has_one_row_per_run_with_its_session(self): self.assertEqual(rows[2]["weather"], "") self.assertEqual(rows[2]["isk_after_millions"], "") + def test_csv_shows_the_cargo_values_of_cargo_runs_and_leaves_them_empty_for_wallet_runs(self): + sid = self.tracker.start_session(ABYSS, starting_isk=1000, weather="Dark", tier=3) + self.tracker.log_abyss_run(sid, 1000, 1030, time_left=10) + self.tracker.log_cargo_run(sid, 50, 92.5, time_left=12) + out = self.dir / "out.csv" + self.tracker.export_csv(out) + with open(out, newline="", encoding="utf-8") as f: + header = next(csv.reader(f)) + self.assertEqual( + header[header.index("profit_millions"):], + ["profit_millions", "minutes", "isk_after_millions", "runs", "cargo_before_millions", + "cargo_after_millions"], + ) # new columns go last, so every older column keeps its place + wallet, cargo = self.rows(out) + self.assertEqual( + (wallet["isk_after_millions"], wallet["cargo_before_millions"], wallet["cargo_after_millions"]), + ("1030.0", "", ""), + ) + self.assertEqual( + (cargo["isk_after_millions"], cargo["cargo_before_millions"], cargo["cargo_after_millions"]), + ("", "50.0", "92.5"), + ) + self.assertEqual(float(cargo["profit_millions"]), 42.5) + def test_csv_can_be_limited_to_one_kind(self): self.seed() out = self.dir / "out.csv" @@ -133,7 +157,15 @@ def test_restore_from_a_database_older_than_the_current_schema(self): self.tracker.restore(old) self.assertEqual(self.tracker.totals().profit, 10) self.assertIsNone(self.tracker.session(1).weather) + self.assertIsNone(self.tracker.runs(1)[0].cargo_after) + self.tracker.log_cargo_run(1, 0, 5, time_left=10) self.tracker.start_session(ABYSS, starting_isk=1, weather="Dark", tier=1) + out = self.dir / "out.csv" + self.assertEqual(self.tracker.export_csv(out), 3) + with open(out, newline="", encoding="utf-8") as f: + old_run, cargo_run, _empty = list(csv.DictReader(f)) + self.assertEqual((old_run["cargo_before_millions"], old_run["cargo_after_millions"]), ("", "")) + self.assertEqual((cargo_run["cargo_before_millions"], cargo_run["cargo_after_millions"]), ("0.0", "5.0")) def test_restore_refuses_files_that_are_not_trackers(self): self.log(50) diff --git a/EveProfitManager/test_pocket.py b/EveProfitManager/test_pocket.py index c9753c8..12b8f2c 100644 --- a/EveProfitManager/test_pocket.py +++ b/EveProfitManager/test_pocket.py @@ -24,7 +24,7 @@ def setUp(self): self.addCleanup(folder.cleanup) self.tracker = Tracker(os.path.join(folder.name, "tracker.db")) self.addCleanup(self.tracker.close) - self.session = self.tracker.start_session(ABYSS, starting_isk=1000, weather="Gamma", tier=2) + self.session = self.tracker.start_session(ABYSS, weather="Gamma", tier=2) self.app = gui.App(self.tracker) self.addCleanup(self.app.destroy) self.app.update() @@ -39,8 +39,11 @@ def test_opens_on_top_in_place_of_the_main_window(self): self.assertEqual(self.app.state(), "withdrawn") self.assertTrue(pocket.attributes("-topmost")) - def test_logs_a_run_from_the_wallet_using_the_shared_timer(self): + def test_logs_a_run_from_cargo_after_using_the_prefilled_cargo_before_and_shared_timer(self): + page = self.app.pages["abyss"] + page.cargo_before.insert(0, "1,000") pocket = self.enter() + self.assertEqual(pocket.amount_label.cget("text"), "Cargo after (M)") pocket.toggle_btn.invoke() self.assertTrue(self.app.pages["abyss"].timer.running) self.app.pages["abyss"].timer.elapsed_before = 8 * 60 # pretend 8 minutes passed @@ -55,6 +58,7 @@ def test_logs_a_run_from_the_wallet_using_the_shared_timer(self): self.assertEqual(pocket.amount.get(), "") self.assertFalse(self.app.pages["abyss"].timer.running) # reset for the next run self.assertIn("+44.5", pocket.message.cget("text")) + self.assertEqual(page.cargo_before.get(), "1044.5") # ready for the next run def test_logging_without_the_timer_explains_and_logs_nothing(self): pocket = self.enter() @@ -65,7 +69,8 @@ def test_logging_without_the_timer_explains_and_logs_nothing(self): def test_a_bad_value_leaves_the_full_window_form_as_it_was(self): page = self.app.pages["abyss"] - page.after_isk.insert(0, "1,200") + page.cargo_before.insert(0, "100") + page.cargo_after.insert(0, "1,200") page.time_left.insert(0, "6") pocket = self.enter() pocket.toggle_btn.invoke() @@ -74,13 +79,13 @@ def test_a_bad_value_leaves_the_full_window_form_as_it_was(self): pocket.log_btn.invoke() self.assertEqual(self.tracker.runs(self.session), []) self.assertIn("number", pocket.message.cget("text")) - self.assertEqual((page.after_isk.get(), page.time_left.get()), ("1,200", "6")) + self.assertEqual((page.cargo_before.get(), page.cargo_after.get(), page.time_left.get()), ("100", "1,200", "6")) def test_expand_keeps_a_value_typed_but_not_logged(self): pocket = self.enter() pocket.amount.insert(0, "1,300") pocket.expand_btn.invoke() - self.assertEqual(self.app.pages["abyss"].after_isk.get(), "1,300") + self.assertEqual(self.app.pages["abyss"].cargo_after.get(), "1,300") def test_a_running_timer_past_zero_still_offers_pause(self): pocket = self.enter() diff --git a/EveProfitManager/test_wallet_page.py b/EveProfitManager/test_wallet_page.py index 42f805d..ce28653 100644 --- a/EveProfitManager/test_wallet_page.py +++ b/EveProfitManager/test_wallet_page.py @@ -39,7 +39,7 @@ def setUp(self): self.addCleanup(folder.cleanup) self.tracker = Tracker(os.path.join(folder.name, "tracker.db")) self.addCleanup(self.tracker.close) - self.tracker.start_session(ABYSS, starting_isk=1000, weather="Gamma", tier=2) + self.tracker.start_session(ABYSS, weather="Gamma", tier=2) sync = mock.patch.object(gui.App, "run_async", lambda _app, work, done: self.finish(work, done)) sync.start() # read on the spot, not on a thread self.addCleanup(sync.stop) @@ -74,7 +74,8 @@ def test_shows_the_balance_from_eve_with_when_it_was_read(self): def test_reads_again_after_each_logged_run(self): self.page.resume() self.wallet.return_value = Wallet(millions=1300.0, fetched_at=READ_AT) - self.page.after_isk.insert(0, "1,050") + self.page.cargo_before.insert(0, "100") + self.page.cargo_after.insert(0, "150") self.page.time_left.insert(0, "10") self.page.log() self.assertEqual(self.wallet.call_count, 2) @@ -83,7 +84,6 @@ def test_reads_again_after_each_logged_run(self): def test_reads_when_a_session_starts(self): self.tracker.end_session(self.page.session_id) self.page.resume() - self.page.start_isk.insert(0, "1,000") self.page.start_session() self.assertEqual(self.wallet.call_count, 1) self.assertEqual(self.shown()[1], "from EVE at 18:40") @@ -100,7 +100,8 @@ def test_a_read_error_shows_in_the_sub_line_and_logging_still_works(self): self.wallet.side_effect = ValueError("ESI returned 403") self.page.resume() self.assertIn("403", self.shown()[1]) - self.page.after_isk.insert(0, "1,050") + self.page.cargo_before.insert(0, "100") + self.page.cargo_after.insert(0, "150") self.page.time_left.insert(0, "10") self.page.log() self.assertEqual(len(self.tracker.runs(self.page.session_id)), 1) diff --git a/docs/adr/0001-run-profit-from-cargo-not-wallet.md b/docs/adr/0001-run-profit-from-cargo-not-wallet.md new file mode 100644 index 0000000..57f54fb --- /dev/null +++ b/docs/adr/0001-run-profit-from-cargo-not-wallet.md @@ -0,0 +1,20 @@ +# Run profit comes from cargo, not the wallet + +An abyssal run's profit used to be the change in the character's wallet. The user holds loot for about a month and sells it all at once, so the wallet barely moves during a session and every run showed a profit of about 0. The "Loot (unsold)" stat from #41 valued the net change across all of the character's assets instead, which also picked up market orders, contracts and purchases, was per session rather than per run, and only refreshed hourly. + +We decided that a run's profit is the change in the active ship's **cargo value**: cargo after minus cargo before, both typed by the user (or fetched from ESI's asset list, valued at CCP's average price). Filaments and ammo used up leave the hold, so they come out of the profit on their own. The wallet no longer takes part in abyssal runs; it stays on the Abyss page as a read-only figure, and the Incursion page keeps reading payouts from the wallet journal. + +## Considered options + +- **Keep the wallet** — rejected: it is right only when loot is sold after every run. +- **Value all assets (Loot (unsold))** — rejected: it mixes in unrelated trades and can't attribute value to a single run. +- **Cargo value per run** — chosen: it measures exactly what a run brought back, works signed out, and survives holding loot. + +## Consequences + +- Runs gain two nullable columns, cargo before and cargo after. The migration only adds them. Old wallet-based runs and sessions are not migrated or recomputed: they keep the profit they were logged with, and a cargo run is told apart from a wallet run by which columns are set. A session left open across the update carries on with cargo runs. +- Editing or deleting a run never re-bases another run, because cargo runs don't chain off each other the way wallet runs did. +- Items without a market price (abyssal modules, for example) can't be valued and are only counted; the user adds them by hand. +- ESI caches assets for up to an hour, so a fetched cargo value can be stale right after a run; the fields stay editable and fetching is a button, not automatic. + +_Rebuilt from the decisions recorded in GitHub issue #38 (grilled 2026-10-25); the original working-tree copy was lost before it was committed._