From 7e1097d8dd5a4063c64ce040dd2cd7d6fe0b0fe1 Mon Sep 17 00:00:00 2001 From: Brad Coudriet Date: Wed, 2 Sep 2026 19:49:29 -0400 Subject: [PATCH 1/2] feat: improve gear entry and weight displays --- README.md | 5 +- gear_core.py | 55 +++++++++------ gear_tui.py | 162 ++++++++++++++++++++++++++++----------------- tests/test_core.py | 18 +++++ tests/test_tui.py | 25 ++++++- 5 files changed, 184 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index edb2085..bc7e35b 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,10 @@ are: item by accident. - **Gear Inventory tab** — search, add, edit, delete gear. The "Review Candidates" button filters to items rated low usefulness (<3/5) *and* - over 8 oz — good first candidates to cut. + over 8 oz — good first candidates to cut. Enter each item's per-unit weight + in ounces; Packrat immediately previews and displays the equivalent ounces, + pounds, and grams throughout the app and its exports. Save and Cancel remain + visible while long gear forms scroll on compact terminals. - **Trips tab** — click a trip to open its dashboard: live weight summary, a colored category-weight bar chart, and the assigned-gear list. Add or remove items right there; the same gear item can be on any number of diff --git a/gear_core.py b/gear_core.py index edf6a94..8daf0b9 100644 --- a/gear_core.py +++ b/gear_core.py @@ -34,6 +34,7 @@ REVIEW_USEFULNESS_THRESHOLD = 3 AUDIT_STATUSES = ("covered", "omitted", "unresolved") DATA_VERSION = 2 +GRAMS_PER_OUNCE = 28.349523125 class DataValidationError(ValueError): """Raised when a data file doesn't match Packrat's expected schema.""" @@ -350,6 +351,16 @@ def total_weight_lb(item): return round(total_weight_oz(item) / 16, 4) +def format_weight_oz(ounces, signed=False): + """Format an ounce value in all display units without changing storage.""" + sign = "+" if signed else "" + return ( + f"{format(ounces, sign + '.1f')} oz · " + f"{format(ounces / 16, sign + '.2f')} lb · " + f"{format(ounces * GRAMS_PER_OUNCE, sign + '.1f')} g" + ) + + def trip_item_weight_oz(gear, entry): """Return the per-trip weight using the assignment quantity.""" return round(gear["weight_oz"] * entry.get("qty", gear.get("qty", 1)), 3) @@ -543,7 +554,7 @@ def render_trip_markdown(data, trip): item_label += f" / {s['unit_count']} total units" meta_bits.append(item_label) if s["target_lb"]: - meta_bits.append(f"target base **{s['target_lb']:.1f} lb**") + meta_bits.append(f"target base **{format_weight_oz(s['target_lb'] * 16)}**") add(" · ".join(meta_bits)) add("") if trip.get("notes"): @@ -556,15 +567,21 @@ def render_trip_markdown(data, trip): status_line = "" if s["delta_lb"] is not None: if s["delta_lb"] <= 0: - status_line = f"✅ **{abs(s['delta_lb']):.2f} lb under** your {s['target_lb']:.1f} lb base weight target" + status_line = ( + f"✅ **{format_weight_oz(abs(s['delta_lb']) * 16)} under** your " + f"{format_weight_oz(s['target_lb'] * 16)} base weight target" + ) else: - status_line = f"⚠️ **{s['delta_lb']:.2f} lb over** your {s['target_lb']:.1f} lb base weight target" + status_line = ( + f"⚠️ **{format_weight_oz(s['delta_lb'] * 16)} over** your " + f"{format_weight_oz(s['target_lb'] * 16)} base weight target" + ) add("| | Weight |") add("|---|---:|") - add(f"| **Base weight** | **{s['base_lb']:.2f} lb** ({s['base_oz']:.1f} oz) |") - add(f"| Worn weight | {s['worn_lb']:.2f} lb ({s['worn_oz']:.1f} oz) |") - add(f"| Consumable weight | {s['consumable_lb']:.2f} lb ({s['consumable_oz']:.1f} oz) |") - add(f"| **Total pack weight** (skin-out) | **{s['total_lb']:.2f} lb** ({s['total_oz']:.1f} oz) |") + add(f"| **Base weight** | **{format_weight_oz(s['base_oz'])}** |") + add(f"| Worn weight | {format_weight_oz(s['worn_oz'])} |") + add(f"| Consumable weight | {format_weight_oz(s['consumable_oz'])} |") + add(f"| **Total pack weight** (skin-out) | **{format_weight_oz(s['total_oz'])}** |") if s["total_cost"]: add(f"| Total gear cost | ${s['total_cost']:,.2f} |") add("") @@ -575,7 +592,7 @@ def render_trip_markdown(data, trip): if s["category_oz"] and s["base_oz"] + s["worn_oz"] + s["consumable_oz"] > 0: big3_pct = pct(s["big_three_oz"], s["total_oz"]) add(f"**The Big Three** (shelter + sleep system + pack): " - f"**{s['big_three_lb']:.2f} lb** — {big3_pct:.0f}% of total pack weight") + f"**{format_weight_oz(s['big_three_oz'])}** — {big3_pct:.0f}% of total pack weight") add("") # --- Weight distribution ------------------------------------------------ @@ -588,7 +605,7 @@ def render_trip_markdown(data, trip): for cat, oz in sorted(s["category_oz"].items(), key=lambda kv: -kv[1]): pct_of_max = pct(oz, max_oz) emoji = CATEGORY_EMOJI.get(cat, "") - add(f"| {emoji} {cat} | {oz:.1f} oz | {pct(oz, s['total_oz']):.0f}% | `{bar(pct_of_max)}` |") + add(f"| {emoji} {cat} | {format_weight_oz(oz)} | {pct(oz, s['total_oz']):.0f}% | `{bar(pct_of_max)}` |") add("") # --- Heaviest items ------------------------------------------------- @@ -598,7 +615,7 @@ def render_trip_markdown(data, trip): add("") for i, row in enumerate(heaviest, start=1): g = row["gear"] - add(f"{i}. **{g['name']}** — {row['total_oz']:.1f} oz ({g['category']})") + add(f"{i}. **{g['name']}** — {format_weight_oz(row['total_oz'])} ({g['category']})") add("") # --- Review candidates ------------------------------------------------- @@ -610,7 +627,7 @@ def render_trip_markdown(data, trip): add("") for row in sorted(review_rows, key=lambda r: -r["total_oz"]): g = row["gear"] - add(f"- **{g['name']}** — {row['total_oz']:.1f} oz, usefulness {g['usefulness']}/5") + add(f"- **{g['name']}** — {format_weight_oz(row['total_oz'])}, usefulness {g['usefulness']}/5") add("") # --- Pack audit --------------------------------------------------------- @@ -637,7 +654,7 @@ def render_trip_markdown(data, trip): continue cat_oz = sum(r["total_oz"] for r in by_cat[cat]) emoji = CATEGORY_EMOJI.get(cat, "") - add(f"### {emoji} {cat} — {cat_oz:.1f} oz ({cat_oz/16:.2f} lb)") + add(f"### {emoji} {cat} — {format_weight_oz(cat_oz)}") add("") for row in sorted(by_cat[cat], key=lambda r: -r["total_oz"]): g = row["gear"] @@ -645,7 +662,7 @@ def render_trip_markdown(data, trip): qty = f" ×{row['trip_qty']}" if row["trip_qty"] > 1 else "" flag = " ⚠️" if row["review_flag"] else "" note = f" — _{row['trip_note']}_" if row["trip_note"] else "" - add(f"- [ ] {g['name']}{brand}{qty} — {row['total_oz']:.1f} oz{note}{flag}") + add(f"- [ ] {g['name']}{brand}{qty} — {format_weight_oz(row['total_oz'])}{note}{flag}") add("") if s["missing_gear_ids"]: @@ -669,7 +686,7 @@ def render_inventory_markdown(data): add("# 🎒 Gear Inventory") add("") - add(f"{len(gear)} items · {total_oz_all:.1f} oz total ({total_oz_all/16:.2f} lb) " + add(f"{len(gear)} items · {format_weight_oz(total_oz_all)} total " f"· ${total_cost_all:,.2f} total value") add("") add(f"*Generated {datetime.now().strftime('%Y-%m-%d %H:%M')}*") @@ -691,7 +708,7 @@ def render_inventory_markdown(data): continue oz = cat_totals[cat] emoji = CATEGORY_EMOJI.get(cat, "") - add(f"| {emoji} {cat} | {oz:.1f} oz | {pct(oz, total_oz_all):.0f}% | " + add(f"| {emoji} {cat} | {format_weight_oz(oz)} | {pct(oz, total_oz_all):.0f}% | " f"`{bar(pct(oz, max_oz))}` |") add("") @@ -700,7 +717,7 @@ def render_inventory_markdown(data): add("## ⚠️ Review Candidates") add("") for g in sorted(flagged, key=lambda x: -total_weight_oz(x)): - add(f"- **{g['name']}** — {total_weight_oz(g):.1f} oz, usefulness {g['usefulness']}/5") + add(f"- **{g['name']}** — {format_weight_oz(total_weight_oz(g))}, usefulness {g['usefulness']}/5") add("") add("## Full Inventory") @@ -710,14 +727,14 @@ def render_inventory_markdown(data): continue cat_oz = cat_totals[cat] emoji = CATEGORY_EMOJI.get(cat, "") - add(f"### {emoji} {cat} — {cat_oz:.1f} oz ({cat_oz/16:.2f} lb)") + add(f"### {emoji} {cat} — {format_weight_oz(cat_oz)}") add("") - add("| Item | Brand | Wt (oz) | Type | Qty | Useful. | Cost | |") + add("| Item | Brand | Weight | Type | Qty | Useful. | Cost | |") add("|---|---|---:|---|---:|---:|---:|---|") for g in sorted(by_cat[cat], key=lambda x: -total_weight_oz(x)): flag = "⚠️" if is_review_flagged(g) else "" cost = f"${g['cost']:,.2f}" if g.get("cost") else "" - add(f"| {g['name']} | {g.get('brand','')} | {total_weight_oz(g):.1f} | " + add(f"| {g['name']} | {g.get('brand','')} | {format_weight_oz(total_weight_oz(g))} | " f"{g['weight_type']} | {g['qty']} | {g['usefulness']}/5 | {cost} | {flag} |") add("") diff --git a/gear_tui.py b/gear_tui.py index d6a2e3b..3c80550 100644 --- a/gear_tui.py +++ b/gear_tui.py @@ -12,6 +12,7 @@ import argparse import copy +import math import os import tempfile from datetime import date @@ -201,6 +202,21 @@ padding-top: 1; } +#dialog.gear-form-dialog { + height: 90%; + overflow-y: hidden; +} + +#gear-form-fields { + height: 1fr; + padding-right: 1; +} + +#f-weight-conversion { + color: #C7D7BC; + padding-top: 0; +} + .field-row { height: auto; } @@ -301,42 +317,61 @@ def __init__(self, mode: str = "add", initial: Optional[dict] = None): def compose(self) -> ComposeResult: title = "Add Gear Item" if self.mode == "add" else f"Edit: {self.initial.get('name','')}" - with Vertical(id="dialog", classes="form-dialog"): + with Vertical(id="dialog", classes="form-dialog gear-form-dialog"): yield Label(title, classes="dialog-title") - yield Label("Category") - yield Select([(c, c) for c in gc.CATEGORIES], id="f-category", allow_blank=False, - value=self.initial.get("category", gc.CATEGORIES[0])) - yield Label("Item Name") - yield Input(value=self.initial.get("name", ""), id="f-name", placeholder="e.g. Solo Tent") - yield Label("Brand / Model") - yield Input(value=self.initial.get("brand", ""), id="f-brand", placeholder="e.g. Big Agnes Copper Spur") - with Horizontal(classes="field-row"): - with Vertical(classes="field-col"): - yield Label("Weight (oz)") - yield Input(value=str(self.initial.get("weight_oz", "")), id="f-weight", type="number") - with Vertical(classes="field-col"): - yield Label("Qty") - yield Input(value=str(self.initial.get("qty", 1)), id="f-qty", type="integer") - yield Label("Weight Type") - yield Select([(t, t) for t in gc.WEIGHT_TYPES], id="f-type", allow_blank=False, - value=self.initial.get("weight_type", "Base Weight")) - with Horizontal(classes="field-row"): - with Vertical(classes="field-col"): - yield Label("Usefulness (1-5)") - yield Select([(str(i), i) for i in range(1, 6)], id="f-usefulness", allow_blank=False, - value=self.initial.get("usefulness", 3)) - with Vertical(classes="field-col"): - yield Label("Cost ($)") - yield Input(value=str(self.initial.get("cost", 0)), id="f-cost", type="number") - yield Label("Notes") - yield Input(value=self.initial.get("notes", ""), id="f-notes") + with VerticalScroll(id="gear-form-fields"): + yield Label("Category") + yield Select([(c, c) for c in gc.CATEGORIES], id="f-category", allow_blank=False, + value=self.initial.get("category", gc.CATEGORIES[0])) + yield Label("Item Name") + yield Input(value=self.initial.get("name", ""), id="f-name", placeholder="e.g. Solo Tent") + yield Label("Brand / Model") + yield Input(value=self.initial.get("brand", ""), id="f-brand", placeholder="e.g. Big Agnes Copper Spur") + with Horizontal(classes="field-row"): + with Vertical(classes="field-col"): + yield Label("Weight per unit (oz)") + yield Input(value=str(self.initial.get("weight_oz", "")), id="f-weight", type="number") + yield Label("Enter ounces to see conversions", id="f-weight-conversion") + with Vertical(classes="field-col"): + yield Label("Qty") + yield Input(value=str(self.initial.get("qty", 1)), id="f-qty", type="integer") + yield Label("Weight Type") + yield Select([(t, t) for t in gc.WEIGHT_TYPES], id="f-type", allow_blank=False, + value=self.initial.get("weight_type", "Base Weight")) + with Horizontal(classes="field-row"): + with Vertical(classes="field-col"): + yield Label("Usefulness (1-5)") + yield Select([(str(i), i) for i in range(1, 6)], id="f-usefulness", allow_blank=False, + value=self.initial.get("usefulness", 3)) + with Vertical(classes="field-col"): + yield Label("Cost ($)") + yield Input(value=str(self.initial.get("cost", 0)), id="f-cost", type="number") + yield Label("Notes") + yield Input(value=self.initial.get("notes", ""), id="f-notes") with Horizontal(classes="dialog-buttons"): yield Button("Cancel", id="f-cancel") yield Button("Save", id="f-save", variant="success") def on_mount(self) -> None: + self._update_weight_conversion(self.query_one("#f-weight", Input).value) self.query_one("#f-name", Input).focus() + @on(Input.Changed, "#f-weight") + def _weight_changed(self, event: Input.Changed) -> None: + self._update_weight_conversion(event.value) + + def _update_weight_conversion(self, value: str) -> None: + preview = self.query_one("#f-weight-conversion", Label) + try: + ounces = float(value) + except ValueError: + preview.update("Enter ounces to see conversions") + return + if not math.isfinite(ounces) or ounces < 0: + preview.update("Weight must be a finite, non-negative number") + return + preview.update(gc.format_weight_oz(ounces) + " per unit") + def action_cancel(self) -> None: self.dismiss(None) @@ -477,7 +512,7 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: table = self.query_one("#gp-table", DataTable) - table.add_columns("ID", "Category", "Item", "Wt (oz)") + table.add_columns("ID", "Category", "Item", "Weight") self._refresh("") self.query_one("#gp-search", Input).focus() @@ -490,7 +525,8 @@ def _refresh(self, text: str) -> None: continue if t and t not in gear_search_blob(g): continue - table.add_row(g["id"], g["category"], g["name"], f"{gc.total_weight_oz(g):.1f}", key=g["id"]) + table.add_row(g["id"], g["category"], g["name"], + gc.format_weight_oz(gc.total_weight_oz(g)), key=g["id"]) @on(Input.Changed, "#gp-search") def _search(self, event: Input.Changed) -> None: @@ -550,7 +586,7 @@ def __init__(self, gear: dict, entry: dict): def compose(self) -> ComposeResult: with Vertical(id="dialog", classes="form-dialog"): yield Label(f"Edit trip item: {self.gear['name']}", classes="dialog-title") - yield Label(f"Inventory weight per unit: {self.gear['weight_oz']:.1f} oz") + yield Label(f"Inventory weight per unit: {gc.format_weight_oz(self.gear['weight_oz'])}") yield Label("Trip quantity") yield Input(value=str(self.entry.get("qty", self.gear.get("qty", 1))), id="ti-qty", type="integer") @@ -691,9 +727,9 @@ def compose(self) -> ComposeResult: yield Footer() def on_mount(self) -> None: - self.query_one("#compare-categories", DataTable).add_columns("Category", "Delta (oz)") + self.query_one("#compare-categories", DataTable).add_columns("Category", "Weight change") self.query_one("#compare-items", DataTable).add_columns( - "Change", "Category", "Item", "Qty", "Delta (oz)") + "Change", "Category", "Item", "Qty", "Weight change") self._refresh(self.other_trips[0]["id"]) @on(Select.Changed, "#compare-trip") @@ -705,28 +741,33 @@ def _refresh(self, right_trip_id: str) -> None: left = gc.find_trip(self.data, self.left_trip_id) right = gc.find_trip(self.data, right_trip_id) comparison = gc.compare_trips(self.data, left, right) + base_delta_oz = comparison["right"]["base_oz"] - comparison["left"]["base_oz"] + total_delta_oz = comparison["right"]["total_oz"] - comparison["left"]["total_oz"] self.query_one("#compare-summary", Static).update( f"[b]{right['name']}[/b] compared with [b]{left['name']}[/b]\n" - f"Base: {comparison['left']['base_lb']:.2f} → {comparison['right']['base_lb']:.2f} lb " - f"({comparison['base_delta_lb']:+.2f} lb) " - f"Skin-out: {comparison['left']['total_lb']:.2f} → " - f"{comparison['right']['total_lb']:.2f} lb ({comparison['total_delta_lb']:+.2f} lb)") + f"Base: {gc.format_weight_oz(comparison['left']['base_oz'])} → " + f"{gc.format_weight_oz(comparison['right']['base_oz'])} " + f"({gc.format_weight_oz(base_delta_oz, signed=True)})\n" + f"Skin-out: {gc.format_weight_oz(comparison['left']['total_oz'])} → " + f"{gc.format_weight_oz(comparison['right']['total_oz'])} " + f"({gc.format_weight_oz(total_delta_oz, signed=True)})") categories = self.query_one("#compare-categories", DataTable) categories.clear() for category, delta in sorted(comparison["category_deltas"].items(), key=lambda item: -abs(item[1])): - categories.add_row(category, f"{delta:+.1f}") + categories.add_row(category, gc.format_weight_oz(delta, signed=True)) items = self.query_one("#compare-items", DataTable) items.clear() for row in comparison["added"]: items.add_row("Added", row["category"], row["name"], str(row["qty"]), - f"+{row['total_oz']:.1f}") + gc.format_weight_oz(row["total_oz"], signed=True)) for row in comparison["removed"]: items.add_row("Removed", row["category"], row["name"], str(row["qty"]), - f"-{row['total_oz']:.1f}") + gc.format_weight_oz(-row["total_oz"], signed=True)) for row in comparison["changed"]: items.add_row("Quantity", row["category"], row["name"], - f"{row['left_qty']}→{row['right_qty']}", f"{row['delta_oz']:+.1f}") + f"{row['left_qty']}→{row['right_qty']}", + gc.format_weight_oz(row["delta_oz"], signed=True)) def action_go_back(self) -> None: self.dismiss() @@ -812,9 +853,9 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.query_one("#dash-cat-table", DataTable).add_columns( - "Category", "Wt (oz)", "Wt (lb)", "Distribution") + "Category", "Weight", "Distribution") self.query_one("#dash-items-table", DataTable).add_columns( - "ID", "Category", "Item", "Qty", "Wt (oz)", "Flag", "Note") + "ID", "Category", "Item", "Qty", "Weight", "Flag", "Note") self.refresh_dashboard() def refresh_dashboard(self) -> None: @@ -829,22 +870,23 @@ def refresh_dashboard(self) -> None: f"🏔️ {trip['name']}" + (f" · {trip['dates']}" if trip.get("dates") else "")) lines = [ - f"Base [b]{s['base_lb']:.2f} lb[/b] ({s['base_oz']:.1f} oz) " - f"Worn {s['worn_oz']:.1f} oz Consumable {s['consumable_oz']:.1f} oz " - f"Total [b]{s['total_lb']:.2f} lb[/b] skin-out" + f"Base [b]{gc.format_weight_oz(s['base_oz'])}[/b]\n" + f"Worn {gc.format_weight_oz(s['worn_oz'])}\n" + f"Consumable {gc.format_weight_oz(s['consumable_oz'])}\n" + f"Total [b]{gc.format_weight_oz(s['total_oz'])}[/b] skin-out" ] if s["target_lb"]: if s["delta_lb"] <= 0: - lines.append(f"[#7CD992]{abs(s['delta_lb']):.2f} lb under target " - f"({s['target_lb']:.1f} lb)[/#7CD992]") + lines.append(f"[#7CD992]{gc.format_weight_oz(abs(s['delta_lb']) * 16)} under target " + f"({gc.format_weight_oz(s['target_lb'] * 16)})[/#7CD992]") else: - lines.append(f"[#E08B6A]{s['delta_lb']:.2f} lb OVER target " - f"({s['target_lb']:.1f} lb)[/#E08B6A]") + lines.append(f"[#E08B6A]{gc.format_weight_oz(s['delta_lb'] * 16)} OVER target " + f"({gc.format_weight_oz(s['target_lb'] * 16)})[/#E08B6A]") else: lines.append("[dim]No target base weight set for this trip.[/dim]") if s["category_oz"] and s["total_oz"]: big3_pct = s["big_three_oz"] / s["total_oz"] * 100 - lines.append(f"Big Three (shelter + sleep + pack): {s['big_three_lb']:.2f} lb " + lines.append(f"Big Three (shelter + sleep + pack): {gc.format_weight_oz(s['big_three_oz'])} " f"({big3_pct:.0f}% of total)") if trip.get("notes"): lines.append(f"[dim]{trip['notes']}[/dim]") @@ -861,7 +903,7 @@ def refresh_dashboard(self) -> None: max_oz = max(s["category_oz"].values()) for cat, oz in sorted(s["category_oz"].items(), key=lambda kv: -kv[1]): pct = (oz / max_oz * 100) if max_oz else 0 - cat_table.add_row(cat, f"{oz:.1f}", f"{oz/16:.2f}", colored_bar(pct, width=20)) + cat_table.add_row(cat, gc.format_weight_oz(oz), colored_bar(pct, width=20)) items_table = self.query_one("#dash-items-table", DataTable) items_table.clear() @@ -869,7 +911,7 @@ def refresh_dashboard(self) -> None: g = row["gear"] flag = "REVIEW" if row["review_flag"] else "" items_table.add_row(g["id"], g["category"], g["name"], str(row["trip_qty"]), - f"{row['total_oz']:.1f}", + gc.format_weight_oz(row["total_oz"]), flag, row["trip_note"], key=g["id"]) def action_go_back(self) -> None: @@ -1029,7 +1071,7 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: table = self.query_one("#gear-table", DataTable) - table.add_columns("ID", "Category", "Item", "Wt (oz)", "Type", "Qty", "Use", "Flag") + table.add_columns("ID", "Category", "Item", "Weight", "Type", "Qty", "Use", "Flag") self.refresh_table() def refresh_table(self, filter_text: str = "", review_only: bool = False) -> None: @@ -1047,7 +1089,7 @@ def refresh_table(self, filter_text: str = "", review_only: bool = False) -> Non if t and t not in gear_search_blob(g): continue flag = "REVIEW" if gc.is_review_flagged(g) else "" - table.add_row(g["id"], g["category"], g["name"], f"{gc.total_weight_oz(g):.1f}", + table.add_row(g["id"], g["category"], g["name"], gc.format_weight_oz(gc.total_weight_oz(g)), g["weight_type"], str(g["qty"]), f"{g['usefulness']}/5", flag, key=g["id"]) count += 1 visible_ids.add(g["id"]) @@ -1197,7 +1239,7 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: table = self.query_one("#trip-table", DataTable) - table.add_columns("ID", "Name", "Dates", "Items", "Base (lb)", "Target (lb)", "vs Target") + table.add_columns("ID", "Name", "Dates", "Items", "Base weight", "Target", "vs Target") self.refresh_table() def refresh_table(self, filter_text: str = "") -> None: @@ -1213,15 +1255,15 @@ def refresh_table(self, filter_text: str = "") -> None: if t and t not in blob: continue s = gc.compute_trip_summary(app.data, trip) - target = f"{s['target_lb']:.1f}" if s["target_lb"] else "-" + target = gc.format_weight_oz(s["target_lb"] * 16) if s["target_lb"] else "-" if s["delta_lb"] is None: delta = "-" elif s["delta_lb"] <= 0: - delta = f"{s['delta_lb']:+.2f} ok" + delta = f"{gc.format_weight_oz(s['delta_lb'] * 16, signed=True)} ok" else: - delta = f"{s['delta_lb']:+.2f} over" + delta = f"{gc.format_weight_oz(s['delta_lb'] * 16, signed=True)} over" table.add_row(trip["id"], trip["name"], trip.get("dates", ""), str(len(trip["items"])), - f"{s['base_lb']:.2f}", target, delta, key=trip["id"]) + gc.format_weight_oz(s["base_oz"]), target, delta, key=trip["id"]) count += 1 visible_ids.add(trip["id"]) if selected_id in visible_ids: diff --git a/tests/test_core.py b/tests/test_core.py index cf48f6c..821e5ca 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -92,6 +92,13 @@ def test_exports_follow_custom_data_path(self): class SummaryTests(unittest.TestCase): + def test_weight_formatter_converts_and_signs_all_units(self): + self.assertEqual(gc.format_weight_oz(0), "0.0 oz · 0.00 lb · 0.0 g") + self.assertEqual(gc.format_weight_oz(16), "16.0 oz · 1.00 lb · 453.6 g") + self.assertEqual(gc.format_weight_oz(2.6), "2.6 oz · 0.16 lb · 73.7 g") + self.assertEqual(gc.format_weight_oz(-1, signed=True), "-1.0 oz · -0.06 lb · -28.3 g") + self.assertEqual(gc.format_weight_oz(1, signed=True), "+1.0 oz · +0.06 lb · +28.3 g") + def test_missing_gear_is_reported_without_breaking_summary(self): data = gc.example_data() data["trips"][0]["items"].append({"gear_id": "G999", "note": "missing"}) @@ -121,6 +128,17 @@ def test_review_candidates_remain_in_export_with_pack_audit(self): self.assertIn("Low usefulness rating and meaningful weight", export) self.assertLess(export.index("## ⚠️ Review Candidates"), export.index("## Pack Audit")) + def test_markdown_exports_show_ounces_pounds_and_grams(self): + data = gc.example_data() + for export in ( + gc.render_trip_markdown(data, data["trips"][0]), + gc.render_inventory_markdown(data), + ): + with self.subTest(export=export[:40]): + self.assertIn(" oz · ", export) + self.assertIn(" lb · ", export) + self.assertIn(" g", export) + class PlanningWorkflowTests(unittest.TestCase): def setUp(self): diff --git a/tests/test_tui.py b/tests/test_tui.py index d0e6899..a3292b9 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -4,7 +4,7 @@ import unittest from pathlib import Path -from textual.widgets import Input, Static, TabbedContent +from textual.widgets import Button, Input, Label, Static, TabbedContent from gear_tui import ( GearFormScreen, @@ -54,6 +54,29 @@ async def test_gear_hotkey_and_ctrl_s_add_an_item(self): self.assertEqual(len(app.data["gear"]), starting_count + 1) self.assertEqual(app.data["gear"][-1]["name"], "test") + async def test_compact_gear_form_keeps_actions_visible_and_previews_weight(self): + with tempfile.TemporaryDirectory() as directory: + app = GearTrackerApp(str(Path(directory) / "gear.json")) + starting_count = len(app.data["gear"]) + async with app.run_test(size=(70, 20)) as pilot: + app.query_one("#gear-table").focus() + await pilot.press("a") + form = app.screen + form.query_one("#f-name", Input).value = "Compact test" + form.query_one("#f-weight", Input).value = "16" + await pilot.pause() + + preview = str(form.query_one("#f-weight-conversion", Label).render()) + self.assertIn("16.0 oz · 1.00 lb · 453.6 g", preview) + save = form.query_one("#f-save", Button) + form.query_one("#gear-form-fields").scroll_end(animate=False) + await pilot.pause() + self.assertTrue(save.is_on_screen) + + await pilot.click("#f-save") + self.assertEqual(len(app.data["gear"]), starting_count + 1) + self.assertEqual(app.data["gear"][-1]["weight_oz"], 16.0) + async def test_duplicate_compare_quantity_and_audit_workflow(self): with tempfile.TemporaryDirectory() as directory: app = GearTrackerApp(str(Path(directory) / "gear.json")) From 814ccd33a237eab0cb51cdcb57f5976afc805ba6 Mon Sep 17 00:00:00 2001 From: Brad Coudriet Date: Wed, 2 Sep 2026 20:07:22 -0400 Subject: [PATCH 2/2] chore: curate repository guidance and tests Replace stale Copilot guidance with shared agent instructions and align the additional pytest suite with schema v2 validation. --- .github/copilot-instructions.md | 186 -------------- AGENTS.md | 86 +++++++ tests/__init__.py | 1 + tests/conftest.py | 10 + tests/unit/test_gear_core.py | 418 ++++++++++++++++++++++++++++++++ 5 files changed, 515 insertions(+), 186 deletions(-) delete mode 100644 .github/copilot-instructions.md create mode 100644 AGENTS.md create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/unit/test_gear_core.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 31e845b..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,186 +0,0 @@ -# Copilot Instructions — Packrat - -## Quick Start - -Install dependencies and run: -```bash -uv run python main.py -``` - -On first run the app asks for a storage folder, remembers it in the platform's standard application-config location, and creates `gear_data.json` there with seeded example data. Use `uv` (recommended) for automatic environment management; fall back to `pip install -r requirements.txt` if needed. - ---- - -## Architecture - -**Two-layer separation:** - -- **`gear_core.py`** — Pure data layer (no `input()`, `print()`, or Textual imports). Handles: - - JSON load/save via atomic temp-file writes - - Trip weight calculations and category breakdowns - - Markdown rendering for exports (pack lists and inventory reports) - - Constants: `CATEGORIES`, `CATEGORY_EMOJI`, `BIG_THREE`, `WEIGHT_TYPES`, thresholds - - Helper functions: `find_gear()`, `find_trip()`, `compute_trip_summary()`, `total_weight_oz()`, etc. - -- **`packrat_preferences.py`** — Cross-platform preference layer. Handles: - - OS-standard config and suggested data directories via `platformdirs` - - Atomic `preferences.json` reads/writes - - Startup precedence between `--data`, remembered storage, and onboarding - -- **`gear_tui.py`** — Textual UI layer. Handles: - - Screens (gear inventory, trips tab, reports tab) and modal dialogs (forms, pickers, confirmations) - - DataTable widgets, search filtering, keyboard + mouse bindings - - CSS styling in `APP_CSS` (dark palette: forest green on very dark background) - - All event handlers delegate persistence to `gear_core` functions - -- **`main.py`** — Entry point; just invokes `gear_tui.main()`. - -**Key principle:** Logic in `gear_core` is testable and reusable; all UI state lives in Textual widgets. - ---- - -## Data Model & Schema - -**Core structure** (defined in `gear_core.blank_data()`): -```python -{ - "meta": {"created": ISO_DATE, "version": 1}, - "gear": [ # list of items - { - "id": "G001", "category": "Shelter", "name": "...", - "brand": "...", "weight_oz": float, "weight_type": "Base Weight|Worn Weight|Consumable", - "qty": int, "usefulness": 1-5, "cost": float, "notes": str, "added": ISO_DATE - }, - ... - ], - "trips": [ # list of trips - { - "id": "T001", "name": "...", "dates": str, "target_base_weight_lb": float, - "notes": str, "created": ISO_DATE, - "items": [{"gear_id": "G001", "note": "trip-specific note"}, ...] - }, - ... - ] -} -``` - -**Adding a new data field:** -1. Update `gear_core.blank_data()` and `example_data()` to include the field -2. Update relevant Markdown render functions (`render_trip_markdown()` or `render_inventory_markdown()`) -3. Add a form field in the corresponding `*FormScreen` class in `gear_tui.py` -4. Wire up the field read/write in form's `_save()` method - ---- - -## Key Conventions - -### Constants & Thresholds -- `CATEGORIES` — 15 predefined gear categories (Shelter, Sleep System, etc.) -- `WEIGHT_TYPES` — ["Base Weight", "Worn Weight", "Consumable"] (not weight units; OZ is assumed) -- `BIG_THREE` — {"Shelter", "Sleep System", "Pack"} — special tracking for ultralight metrics -- `REVIEW_WEIGHT_THRESHOLD_OZ = 8.0` and `REVIEW_USEFULNESS_THRESHOLD = 3` — flags for "Review Candidates" - -### ID Generation -- `next_id(items, prefix)` — generates sequential IDs: G001, G002, ... and T001, T002, ... -- IDs are strings; always use them as strings in data structures - -### Weight Math -- Everything is in **ounces** internally; conversions to lb happen only in display/export -- `total_weight_oz(item)` — single item total = `weight_oz * qty` -- `compute_trip_summary()` — aggregates all trip items into base/worn/consumable oz and category breakdown - -### Data Persistence -- `load_data(path)` — reads JSON; auto-seeds with `example_data()` if file missing -- `save_data(path, data)` — atomic writes via temp file (`path + ".tmp"`, then `os.replace()`) -- The selected folder is remembered outside the repository; its library is always named `gear_data.json` -- `--data` is an exact-file override for one launch and does not update preferences -- **No locking** — avoid editing from multiple machines simultaneously or sync conflicts will create backup files - -### Markdown Export -- `render_trip_markdown(data, trip)` — polished pack list with weights, charts, review flags, checkboxes -- `render_inventory_markdown(data)` — full inventory report with category breakdown -- Both use `bar(percent, width)` for ASCII bar charts; output must be readable as plain text - ---- - -## UI Patterns (Textual) - -### Screens & Navigation -- **`InventoryScreen`** — tables gear, search filter, add/edit/delete, "Review Candidates" button -- **`TripsScreen`** — lists trips, click to open trip dashboard (summary + assigned items + assign/remove) -- **`ReportsScreen`** — buttons to export trip pack lists or full inventory to `exports/` folder -- Modals: `GearFormScreen`, `TripFormScreen`, `GearPickerScreen`, `ConfirmScreen` - -### Form Validation -- Do **not** save if required fields empty; use `self.app.notify(..., severity="error")` -- Cast numeric inputs in `_save()` with try/except; show error if invalid -- Always `.strip()` text inputs before saving - -### Styling -- **All CSS is in `APP_CSS`** at top of `gear_tui.py` — one place to change colors -- Accent green: `#6EE7B7` (mint); sage: `#4A7856`; text: `#E7F5DA`; muted: `#9AAE8C` -- DataTable cursor highlight, button states, and dialog borders use these colors - -### DataTable Rows & Events -- Use `@on(DataTable.RowSelected)` to highlight; double-click or Enter to open/edit -- Keyboard binding: `q` to quit, `Esc` to cancel dialogs -- Search filtering: live as user types (rebuild table from filtered list) - ---- - -## Development Workflow - -### Running the App -```bash -uv run python main.py -``` - -### Testing in the UI -- No unit test suite in the root; use headless Textual Pilot tests if available in dev branch -- Manually test: add gear, create trip, assign items, verify weights, export Markdown -- Terminal must be ≥130 × 42 characters for forms to display fully - -### Adding a New Feature - -**New data field:** -- Edit `blank_data()` → add field with default value -- Edit `example_data()` → add field to example item -- Edit `render_trip_markdown()` or similar → include new field in output -- Add form widget in `gear_tui.py` (label + Input/Select) - -**New screen:** -- Subclass `Screen` in `gear_tui.py` -- Add `compose()` to build widgets, bindings, CSS classes -- Add action methods for keyboard shortcuts -- Wire from main app or existing screen via `self.app.push_screen(NewScreen())` - -**Export format changes:** -- Edit `render_trip_markdown()` or `render_inventory_markdown()` in `gear_core.py` -- Test by running export in the UI and checking the generated `.md` file -- Keep output plain-text readable (no fancy Unicode that breaks on older terminals) - -### Dependency Changes -- Modify `pyproject.toml` manually or install new package via `uv add ` -- Commit both `pyproject.toml` and `uv.lock` to ensure reproducible builds -- Fallback: run `pip freeze > requirements.txt` for pip-only users - ---- - -## Common Gotchas - -- **Modal dialogs cut off:** Terminal too small (need ≥130×42). Zoom or expand window. -- **Data file not created:** Check write permissions in the selected storage folder. -- **Markdown export missing:** Files go to `exports/` subdirectory auto-created next to `gear_data.json`. -- **Weight calculations wrong:** Verify `qty` is set (defaults to 1) and `weight_type` matches the calculation logic (base/worn/consumable are summed separately). -- **Stale UI after edit:** Modal dismisses and returns updated dict; catch with `@on(SomeScreen.ScreenType.Submitted)` or similar pattern. - ---- - -## Code Style Notes - -- Keep `gear_core.py` free of side effects — it must be testable without mocking filesystem or terminal -- Comment only where logic is non-obvious; code is self-documenting otherwise -- Use descriptive variable names; e.g., `rows_sorted_by_weight`, not `r` -- Textual widgets: prefer `@on()` decorators over manual event binding -- Always use `self.query_one(selector, WidgetClass)` with type hint for safety - diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9ba4aee --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# Packrat Repository Guide + +This file is the authoritative guidance for coding agents working in this repository. + +## Project Overview + +Packrat is a Python 3.9+ terminal application for maintaining a backpacking gear inventory and building trip-specific pack lists. The UI uses Textual. User data is stored in a portable JSON library selected during onboarding; it is not normally the repository's `gear_data.json`. + +## Commands + +Use `uv` for the project environment. + +```bash +# Run the application +uv run python main.py + +# Run the committed unittest suite (including headless Textual tests) +uv run python -m unittest discover -s tests -v + +# Run all pytest-style and unittest-style tests when pytest is available +uv run pytest +``` + +For a disposable library during manual testing, pass an exact file path: + +```bash +uv run python main.py --data /tmp/packrat-test/gear_data.json +``` + +Do not use a developer's remembered personal library for automated or destructive testing. + +## Architecture and Ownership + +- `main.py`: minimal entry point; delegates to `gear_tui.main()`. +- `gear_core.py`: UI-independent data model, validation, calculations, persistence, backups, comparisons, audits, and Markdown rendering. Keep it free of Textual imports and terminal I/O. +- `packrat_preferences.py`: cross-platform config/data paths, atomic preference persistence, and startup-path precedence. +- `gear_tui.py`: Textual screens, widgets, bindings, application state, and CSS (`APP_CSS`). UI handlers should delegate domain logic and persistence to `gear_core.py`. +- `tests/test_core.py`, `tests/test_preferences.py`, `tests/test_tui.py`: current behavioral and headless workflow coverage. +- `tests/unit/`: additional unit coverage; preserve compatibility with it when changing core behavior. + +Maintain the boundary between the pure core and the UI. If behavior can be expressed without Textual widgets, implement it in `gear_core.py` and test it there. + +## Data and Persistence Invariants + +- `gear_core.DATA_VERSION` is the current schema version. `validate_data()` accepts supported older data, supplies backward-compatible defaults, rejects ambiguous/invalid values, and upgrades the in-memory version. +- Gear and trip IDs are non-empty strings and unique within their collection. Generate sequential IDs with `next_id()` (`G001`, `T001`, and so on). +- All stored weights are ounces. Convert to pounds only for display or export. +- Inventory quantity and trip-specific quantity are distinct. A trip item contains `gear_id`, `qty`, and `note`; calculations must use the trip quantity. +- Trip audit values must use `AUDIT_STATUSES`; categories and weight types must use the canonical constants. +- Validate the complete model before saving. Preserve atomic writes, `.bak` creation, file signatures, conflict detection, and rollback-on-failure behavior. +- Exports belong in the `exports/` directory beside the active data file, as determined by `export_dir_for_data()`. +- The `--data` option is a one-launch exact-file override and must not update remembered preferences. +- The selected persistent storage directory always contains a library named `gear_data.json`. + +When adding or changing persisted fields, update all affected layers together: + +1. `blank_data()`, `example_data()`, validation/defaulting, and schema version/migration behavior as appropriate. +2. Calculations, duplication/comparison/audit logic, and Markdown renderers that consume the field. +3. The relevant Textual form and save/read paths. +4. Core tests plus headless UI coverage for user-visible workflows. + +Never silently coerce malformed persisted values when doing so could change pack weights or meaning. + +## UI Conventions + +- Keep visual styling centralized in `APP_CSS`. +- Preserve keyboard and mouse parity. Existing global bindings include `1`/`2`/`3`, `/`, `?`, `Ctrl+B`, `Ctrl+P`, `q`, and `Ctrl+C`. +- Use `@on(...)` handlers and typed `query_one(selector, WidgetClass)` calls where practical. +- Strip text input, parse numeric fields explicitly, and notify with `severity="error"` rather than dismissing a form with invalid data. +- Modal forms use `Ctrl+S` to save and `Esc` to cancel. Confirmations must remain explicit for destructive actions. +- After a mutation, persist through `GearTrackerApp.save()` and refresh the affected view. Do not bypass its conflict handling or rollback behavior. + +## Testing Expectations + +- Add or update tests with every behavioral change. +- Prefer focused core tests for calculations, validation, migration, persistence, and Markdown output. +- Use Textual Pilot tests for navigation, bindings, dialogs, and complete keyboard workflows; do not rely only on manual UI checks. +- Use temporary directories and injected preference paths for filesystem tests. Tests must not read or modify real user preferences or libraries. +- Run at least `uv run python -m unittest discover -s tests -v` before handing off a change. Run `uv run pytest` too when modifying behavior covered by `tests/unit/`. +- Maintain Python 3.9 compatibility; avoid syntax and standard-library APIs introduced later. + +## Dependencies and Style + +- Add runtime dependencies to `pyproject.toml` with `uv add`; commit the resulting `uv.lock` change. Keep `requirements.txt` aligned for pip users when runtime dependencies change. +- Use clear names and small, focused functions. Comment rationale and non-obvious constraints, not mechanics visible from the code. +- Preserve unrelated working-tree changes. Generated caches, logs, personal data, and exports should not be committed unless the task explicitly requires a fixture or artifact. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..66173ae --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Test package diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..81608e2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +"""Pytest configuration.""" + +import pytest + + +@pytest.fixture(autouse=True) +def reset_env_vars(monkeypatch): + """Reset environment variables that might affect tests.""" + monkeypatch.delenv("TEXTUAL_LOG_FILE", raising=False) + monkeypatch.delenv("TEXTUAL_LOG_LEVEL", raising=False) diff --git a/tests/unit/test_gear_core.py b/tests/unit/test_gear_core.py new file mode 100644 index 0000000..56c096e --- /dev/null +++ b/tests/unit/test_gear_core.py @@ -0,0 +1,418 @@ +"""Unit tests for gear_core.py""" + +import json +import os +import tempfile +from datetime import date + +import pytest + +import gear_core as gc + + +def valid_gear(**overrides): + gear = { + "id": "G001", + "category": "Shelter", + "name": "Test", + "brand": "", + "weight_oz": 1.0, + "weight_type": "Base Weight", + "qty": 1, + "usefulness": 3, + "cost": 0.0, + "notes": "", + "added": date.today().isoformat(), + } + gear.update(overrides) + return gear + + +class TestBlankData: + """Test blank_data function.""" + + def test_returns_empty_structure(self): + data = gc.blank_data() + assert data["meta"]["created"] == date.today().isoformat() + assert data["meta"]["version"] == gc.DATA_VERSION + assert data["gear"] == [] + assert data["trips"] == [] + + +class TestExampleData: + """Test example_data function.""" + + def test_returns_populated_structure(self): + data = gc.example_data() + assert len(data["gear"]) == 6 + assert len(data["trips"]) == 1 + assert data["gear"][0]["id"] == "G001" + assert data["gear"][0]["category"] == "Shelter" + assert data["trips"][0]["id"] == "T001" + assert data["trips"][0]["name"] == "VA Triple Crown (EXAMPLE - delete me)" + + +class TestLoadData: + """Test load_data function.""" + + def test_returns_example_data_for_missing_file(self, tmp_path): + path = tmp_path / "nonexistent.json" + data = gc.load_data(str(path)) + assert len(data["gear"]) == 6 + + def test_rejects_empty_file(self, tmp_path): + path = tmp_path / "empty.json" + path.touch() + with pytest.raises(gc.DataValidationError, match="invalid JSON"): + gc.load_data(str(path)) + + def test_loads_existing_file(self, tmp_path): + path = tmp_path / "data.json" + test_data = gc.blank_data() + test_data["gear"] = [valid_gear()] + with open(path, "w") as f: + json.dump(test_data, f) + data = gc.load_data(str(path)) + assert len(data["gear"]) == 1 + assert data["gear"][0]["id"] == "G001" + + def test_adds_defaults_to_missing_fields(self, tmp_path): + path = tmp_path / "incomplete.json" + test_data = { + "meta": {"created": date.today().isoformat(), "version": 1}, + "gear": [{ + "id": "G001", + "category": "Shelter", + "name": "Test", + "weight_oz": 1.0, + "weight_type": "Base Weight", + "qty": 1, + "usefulness": 3, + }], + } + with open(path, "w") as f: + json.dump(test_data, f) + data = gc.load_data(str(path)) + assert data["meta"]["created"] is not None + assert data["meta"]["version"] == gc.DATA_VERSION + assert data["gear"][0]["brand"] == "" + assert data["gear"][0]["cost"] == 0.0 + assert data["gear"][0]["notes"] == "" + assert data["trips"] == [] + + +class TestSaveData: + """Test save_data function.""" + + def test_creates_parent_directory(self, tmp_path): + path = tmp_path / "subdir" / "data.json" + data = {"gear": []} + gc.save_data(str(path), data) + assert (tmp_path / "subdir" / "data.json").exists() + + def test_writes_valid_json(self, tmp_path): + path = tmp_path / "data.json" + data = gc.blank_data() + data["gear"] = [valid_gear()] + gc.save_data(str(path), data) + with open(path) as f: + saved = json.load(f) + assert saved["gear"] == [valid_gear()] + + def test_atomic_write(self, tmp_path): + path = tmp_path / "data.json" + data = gc.blank_data() + data["gear"] = [valid_gear()] + gc.save_data(str(path), data) + # Verify no .tmp file left behind + assert not (tmp_path / "data.json.tmp").exists() + + +class TestNextId: + """Test next_id function.""" + + def test_generates_next_id_for_empty_list(self): + assert gc.next_id([], "G") == "G001" + + def test_generates_next_id_for_existing_ids(self): + items = [{"id": "G001"}, {"id": "G005"}, {"id": "G010"}] + assert gc.next_id(items, "G") == "G011" + + def test_handles_invalid_ids(self): + items = [{"id": "INVALID"}, {"id": "G001"}, {"id": "G002"}] + assert gc.next_id(items, "G") == "G003" + + +class TestFindGear: + """Test find_gear function.""" + + def test_finds_existing_gear(self): + data = {"gear": [{"id": "G001", "category": "Shelter"}]} + result = gc.find_gear(data, "G001") + assert result is not None + assert result["id"] == "G001" + + def test_returns_none_for_missing_gear(self): + data = {"gear": [{"id": "G001"}]} + result = gc.find_gear(data, "G999") + assert result is None + + +class TestFindTrip: + """Test find_trip function.""" + + def test_finds_existing_trip(self): + data = {"trips": [{"id": "T001", "name": "Test Trip"}]} + result = gc.find_trip(data, "T001") + assert result is not None + assert result["id"] == "T001" + + def test_returns_none_for_missing_trip(self): + data = {"trips": [{"id": "T001"}]} + result = gc.find_trip(data, "T999") + assert result is None + + +class TestTotalWeight: + """Test weight calculation functions.""" + + def test_total_weight_oz(self): + item = {"weight_oz": 10.0, "qty": 2} + assert gc.total_weight_oz(item) == 20.0 + + def test_total_weight_oz_rounding(self): + item = {"weight_oz": 10.0, "qty": 3} + result = gc.total_weight_oz(item) + assert result == 30.0 + + def test_total_weight_lb(self): + item = {"weight_oz": 16.0, "qty": 1} + assert gc.total_weight_lb(item) == 1.0 + + def test_total_weight_lb_rounding(self): + item = {"weight_oz": 17.0, "qty": 1} + result = gc.total_weight_lb(item) + assert result == 1.0625 + + +class TestIsReviewFlagged: + """Test is_review_flagged function.""" + + def test_flagged_low_usefulness_high_weight(self): + item = {"usefulness": 2, "weight_oz": 10.0, "qty": 1} + assert gc.is_review_flagged(item) is True + + def test_not_flagged_high_usefulness(self): + item = {"usefulness": 5, "weight_oz": 20.0, "qty": 1} + assert gc.is_review_flagged(item) is False + + def test_not_flagged_low_weight(self): + item = {"usefulness": 1, "weight_oz": 5.0, "qty": 1} + assert gc.is_review_flagged(item) is False + + +class TestTripsReferencingGear: + """Test trips_referencing_gear function.""" + + def test_finds_all_refs(self): + data = { + "gear": [{"id": "G001"}], + "trips": [ + {"id": "T001", "items": [{"gear_id": "G001"}]}, + {"id": "T002", "items": [{"gear_id": "G001"}]}, + ] + } + refs = gc.trips_referencing_gear(data, "G001") + assert len(refs) == 2 + assert refs[0]["id"] == "T001" + assert refs[1]["id"] == "T002" + + def test_no_refs(self): + data = { + "gear": [{"id": "G001"}], + "trips": [{"id": "T001", "items": [{"gear_id": "G999"}]}] + } + refs = gc.trips_referencing_gear(data, "G001") + assert refs == [] + + +class TestComputeTripSummary: + """Test compute_trip_summary function.""" + + def test_basic_calculation(self): + data = { + "gear": [ + {"id": "G001", "category": "Shelter", "weight_oz": 30.0, "weight_type": "Base Weight", "qty": 1, "usefulness": 4}, + {"id": "G002", "category": "Sleep System", "weight_oz": 29.0, "weight_type": "Base Weight", "qty": 1, "usefulness": 5}, + ], + "trips": [{"id": "T001", "items": [{"gear_id": "G001"}, {"gear_id": "G002"}]}] + } + trip = data["trips"][0] + summary = gc.compute_trip_summary(data, trip) + assert summary["base_oz"] == 59.0 + assert summary["base_lb"] == 3.688 # 59/16 = 3.6875, rounded to 3 decimal places + assert summary["item_count"] == 2 + + def test_missing_gear(self): + data = { + "gear": [{"id": "G001", "category": "Shelter", "weight_oz": 30.0, "weight_type": "Base Weight", "qty": 1, "usefulness": 4}], + "trips": [{"id": "T001", "items": [{"gear_id": "G001"}, {"gear_id": "G999"}]}] + } + summary = gc.compute_trip_summary(data, data["trips"][0]) + assert "G999" in summary["missing_gear_ids"] + + def test_category_breakdown(self): + data = { + "gear": [ + {"id": "G001", "category": "Shelter", "weight_oz": 30.0, "weight_type": "Base Weight", "qty": 1, "usefulness": 4}, + {"id": "G002", "category": "Cook System", "weight_oz": 5.0, "weight_type": "Base Weight", "qty": 1, "usefulness": 4}, + ], + "trips": [{"id": "T001", "items": [{"gear_id": "G001"}, {"gear_id": "G002"}]}] + } + summary = gc.compute_trip_summary(data, data["trips"][0]) + assert summary["category_oz"]["Shelter"] == 30.0 + assert summary["category_oz"]["Cook System"] == 5.0 + + def test_big_three_calculation(self): + data = { + "gear": [ + {"id": "G001", "category": "Shelter", "weight_oz": 30.0, "weight_type": "Base Weight", "qty": 1, "usefulness": 4}, + {"id": "G002", "category": "Sleep System", "weight_oz": 29.0, "weight_type": "Base Weight", "qty": 1, "usefulness": 5}, + {"id": "G003", "category": "Pack", "weight_oz": 50.0, "weight_type": "Base Weight", "qty": 1, "usefulness": 4}, + ], + "trips": [{"id": "T001", "items": [{"gear_id": "G001"}, {"gear_id": "G002"}, {"gear_id": "G003"}]}] + } + summary = gc.compute_trip_summary(data, data["trips"][0]) + assert summary["big_three_oz"] == 109.0 + assert summary["big_three_lb"] == 6.812 # 109/16 = 6.8125, rounded to 3 decimal places + + def test_weight_type_breakdown(self): + data = { + "gear": [ + {"id": "G001", "category": "Shelter", "weight_oz": 30.0, "weight_type": "Base Weight", "qty": 1, "usefulness": 4}, + {"id": "G002", "category": "Clothing - Worn", "weight_oz": 10.0, "weight_type": "Worn Weight", "qty": 1, "usefulness": 4}, + {"id": "G003", "category": "Food", "weight_oz": 20.0, "weight_type": "Consumable", "qty": 1, "usefulness": 4}, + ], + "trips": [{"id": "T001", "items": [{"gear_id": "G001"}, {"gear_id": "G002"}, {"gear_id": "G003"}]}] + } + summary = gc.compute_trip_summary(data, data["trips"][0]) + assert summary["base_oz"] == 30.0 + assert summary["worn_oz"] == 10.0 + assert summary["consumable_oz"] == 20.0 + + +class TestBar: + """Test bar function.""" + + def test_bar_100_percent(self): + result = gc.bar(100.0) + assert "█" in result + assert "░" not in result + + def test_bar_0_percent(self): + result = gc.bar(0.0) + assert "█" not in result + assert "░" in result + + def test_bar_clamped(self): + assert gc.bar(-10.0) == gc.bar(0.0) + assert gc.bar(110.0) == gc.bar(100.0) + + +class TestPct: + """Test pct function.""" + + def test_pct_calculation(self): + assert gc.pct(50.0, 100.0) == 50.0 + + def test_pct_zero_whole(self): + assert gc.pct(50.0, 0.0) == 0.0 + + +class TestRenderInventoryMarkdown: + """Test render_inventory_markdown function.""" + + def test_basic_render(self): + data = gc.example_data() + md = gc.render_inventory_markdown(data) + assert "# 🎒 Gear Inventory" in md + assert "Solo Tent" in md + assert "Sleeping Bag" in md + + def test_render_empty_inventory(self): + data = gc.blank_data() + md = gc.render_inventory_markdown(data) + assert "# 🎒 Gear Inventory" in md + assert "0 items" in md + + +class TestRenderTripMarkdown: + """Test render_trip_markdown function.""" + + def test_basic_render(self): + data = gc.example_data() + trip = data["trips"][0] + md = gc.render_trip_markdown(data, trip) + assert f"# 🏔️ {trip['name']}" in md + assert "Summary" in md + assert "Base weight" in md + + def test_render_with_missing_gear(self): + data = { + "gear": [{"id": "G001", "category": "Shelter", "weight_oz": 30.0, "weight_type": "Base Weight", "qty": 1, "usefulness": 4, "name": "Test Item"}], + "trips": [{"id": "T001", "name": "Test Trip", "items": [{"gear_id": "G001"}, {"gear_id": "G999"}]}] + } + md = gc.render_trip_markdown(data, data["trips"][0]) + assert "Warnings" in md + assert "G999" in md + + def test_render_with_review_candidates(self): + data = { + "gear": [ + {"id": "G001", "category": "Shelter", "weight_oz": 30.0, "weight_type": "Base Weight", "qty": 1, "usefulness": 2, "name": "Test Item"}, + ], + "trips": [{"id": "T001", "name": "Test Trip", "items": [{"gear_id": "G001"}]}] + } + md = gc.render_trip_markdown(data, data["trips"][0]) + assert "Review Candidates" in md + assert "Low usefulness rating and meaningful weight" in md + + def test_render_with_target_weight(self): + data = { + "gear": [{"id": "G001", "category": "Shelter", "weight_oz": 10.0, "weight_type": "Base Weight", "qty": 1, "usefulness": 4, "name": "Test Item"}], + "trips": [{"id": "T001", "name": "Test Trip", "target_base_weight_lb": 5.0, "items": [{"gear_id": "G001"}]}] + } + md = gc.render_trip_markdown(data, data["trips"][0]) + assert "under" in md.lower() + + def test_render_over_target(self): + data = { + "gear": [{"id": "G001", "category": "Shelter", "weight_oz": 100.0, "weight_type": "Base Weight", "qty": 1, "usefulness": 4, "name": "Test Item"}], + "trips": [{"id": "T001", "name": "Test Trip", "target_base_weight_lb": 5.0, "items": [{"gear_id": "G001"}]}] + } + md = gc.render_trip_markdown(data, data["trips"][0]) + assert "over" in md.lower() + + +class TestSafeFilename: + """Test safe_filename function.""" + + def test_basic_filename(self): + assert gc.safe_filename("Test Trip") == "Test_Trip" + + def test_filename_with_special_chars(self): + assert gc.safe_filename("Trip's Notes!") == "Trips_Notes" + + def test_empty_filename(self): + assert gc.safe_filename("") == "export" + + def test_filename_with_underscores(self): + assert gc.safe_filename("My_Trip") == "My_Trip" + + +class TestValidationError: + """Test DataValidationError exception.""" + + def test_raises_exception(self): + with pytest.raises(gc.DataValidationError): + raise gc.DataValidationError("Test error")