Simplify battery management: bulk add, device-level auto-install, mass operations

- Replace single-battery add form with bulk add (brand + count, auto-generated labels)
- Add device-level install form: specify brand+qty pairs, system autoselects available batteries
- Add bulk actions on dashboard: retire, delete, unassign, change brand (checkbox multi-select)
- Keep per-battery assign route for special cases (e.g. known low-capacity battery)
- Remove unique constraint on Battery.label (labels are now auto-generated)
- Add *.snapshot to .gitignore for DB snapshot files
- Rewrite tests: 35 passing (11 new tests for bulk-add, device-install, bulk-actions)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 12:57:10 -05:00
parent 2e36d5f53c
commit 1f5234a3e9
8 changed files with 403 additions and 131 deletions
+11 -4
View File
@@ -31,11 +31,18 @@ def client(app):
@pytest.fixture()
def seeded_client(app):
"""Test client pre-loaded with 2 devices and 3 batteries (2 available, 1 retired)."""
"""Test client pre-loaded with 2 devices and 3 batteries (2 available, 1 retired).
Batteries:
id=1 BrandX 001 (BrandX, available)
id=2 BrandY 001 (BrandY, available)
id=3 BrandX 002 (BrandX, retired)
"""
with app.test_client() as c:
c.post("/device/add", data={"name": "Device A", "battery_slots": "2"})
c.post("/device/add", data={"name": "Device B", "battery_slots": "1"})
c.post("/battery/add", data={"label": "TST-01", "brand": "BrandX", "status": "available"})
c.post("/battery/add", data={"label": "TST-02", "brand": "BrandY", "status": "available"})
c.post("/battery/add", data={"label": "TST-03", "brand": "BrandX", "status": "retired"})
c.post("/battery/add", data={"brand": "BrandX", "count": "1"}) # id=1
c.post("/battery/add", data={"brand": "BrandY", "count": "1"}) # id=2
c.post("/battery/add", data={"brand": "BrandX", "count": "1"}) # id=3
c.post("/battery/3/retire")
yield c
+138 -41
View File
@@ -4,7 +4,8 @@ Acceptance tests for Battery Tracker.
Each test gets a fresh in-memory SQLite database via the fixtures in conftest.py.
The `seeded_client` fixture pre-populates:
Devices: Device A (2 slots), Device B (1 slot)
Batteries: TST-01 (BrandX, available), TST-02 (BrandY, available), TST-03 (BrandX, retired)
Batteries: BrandX 001 (id=1, available), BrandY 001 (id=2, available),
BrandX 002 (id=3, retired)
"""
import pytest
@@ -17,7 +18,6 @@ import pytest
def get_location(resp):
"""Return the redirect Location header (without host)."""
loc = resp.headers.get("Location", "")
# Strip scheme+host if present
if loc.startswith("http"):
from urllib.parse import urlparse
loc = urlparse(loc).path
@@ -36,34 +36,37 @@ def follow(client, resp):
def test_dashboard_loads(seeded_client):
resp = seeded_client.get("/")
assert resp.status_code == 200
assert b"TST-01" in resp.data
assert b"TST-02" in resp.data
assert b"TST-03" in resp.data
assert b"BrandX 001" in resp.data
assert b"BrandY 001" in resp.data
assert b"BrandX 002" in resp.data
# ------------------------------------------------------------------ #
# Battery — add
# Battery — bulk add
# ------------------------------------------------------------------ #
def test_add_battery(client):
def test_bulk_add_batteries(client):
resp = client.post("/battery/add",
data={"label": "NEW-01", "brand": "TestBrand", "status": "available"},
data={"brand": "Eneloop", "count": "3"},
follow_redirects=True)
assert resp.status_code == 200
assert b"NEW-01" in resp.data
assert b"Eneloop 001" in resp.data
assert b"Eneloop 002" in resp.data
assert b"Eneloop 003" in resp.data
def test_add_battery_duplicate_label(seeded_client):
resp = seeded_client.post("/battery/add",
data={"label": "TST-01", "brand": "OtherBrand", "status": "available"})
def test_bulk_add_autogenerates_sequential_labels(client):
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
resp = client.post("/battery/add", data={"brand": "Eneloop", "count": "2"},
follow_redirects=True)
assert b"Eneloop 003" in resp.data
assert b"Eneloop 004" in resp.data
def test_bulk_add_missing_brand(client):
resp = client.post("/battery/add", data={"brand": "", "count": "1"})
assert resp.status_code == 400
assert b"already exists" in resp.data
def test_add_battery_missing_fields(client):
resp = client.post("/battery/add", data={"label": "", "brand": ""})
assert resp.status_code == 400
assert b"required" in resp.data
assert b"required" in resp.data.lower()
# ------------------------------------------------------------------ #
@@ -73,7 +76,7 @@ def test_add_battery_missing_fields(client):
def test_battery_detail(seeded_client):
resp = seeded_client.get("/battery/1")
assert resp.status_code == 200
assert b"TST-01" in resp.data
assert b"BrandX 001" in resp.data
assert b"BrandX" in resp.data
@@ -93,11 +96,10 @@ def test_edit_notes(seeded_client):
# ------------------------------------------------------------------ #
# Battery — assign
# Battery — assign (per-battery, kept for special cases)
# ------------------------------------------------------------------ #
def test_assign_battery(seeded_client):
# TST-01 is battery id=1, Device A is device id=1
resp = seeded_client.post("/battery/1/assign",
data={"device_id": "1"},
follow_redirects=True)
@@ -106,7 +108,7 @@ def test_assign_battery(seeded_client):
def test_assign_retired_battery_blocked(seeded_client):
# TST-03 is retired (id=3)
# id=3 is retired
resp = seeded_client.post("/battery/3/assign",
data={"device_id": "1"},
follow_redirects=True)
@@ -115,9 +117,9 @@ def test_assign_retired_battery_blocked(seeded_client):
def test_assign_over_capacity_blocked(seeded_client):
# Fill Device B (1 slot) with TST-01
# Fill Device B (1 slot) with id=1
seeded_client.post("/battery/1/assign", data={"device_id": "2"})
# Try to assign TST-02 to the same full device
# Try to assign id=2 to the same full device
resp = seeded_client.post("/battery/2/assign",
data={"device_id": "2"},
follow_redirects=True)
@@ -126,14 +128,13 @@ def test_assign_over_capacity_blocked(seeded_client):
def test_brand_mix_warning(seeded_client):
# Assign TST-01 (BrandX) to Device A
# Assign BrandX 001 (id=1) to Device A
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
# Assign TST-02 (BrandY) to same Device A → brand mix warning, but succeeds
# Assign BrandY 001 (id=2) to same Device A → brand mix warning, but succeeds
resp = seeded_client.post("/battery/2/assign",
data={"device_id": "1"},
follow_redirects=True)
assert resp.status_code == 200
# Warning flash should mention mixing
assert b"mixing" in resp.data.lower() or b"mix" in resp.data.lower() or b"brand" in resp.data.lower()
@@ -145,7 +146,6 @@ def test_unassign_battery(seeded_client):
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
resp = seeded_client.post("/battery/1/unassign", follow_redirects=True)
assert resp.status_code == 200
# Battery should show as available on dashboard
resp2 = seeded_client.get("/battery/1")
assert b"available" in resp2.data.lower()
@@ -169,7 +169,7 @@ def test_retire_clears_device(seeded_client):
def test_retire_already_retired(seeded_client):
# TST-03 is already retired (id=3)
# id=3 is already retired by fixture
resp = seeded_client.post("/battery/3/retire", follow_redirects=True)
assert resp.status_code == 200
assert b"already retired" in resp.data.lower()
@@ -183,7 +183,7 @@ def test_delete_battery_confirmation_page(seeded_client):
resp = seeded_client.get("/battery/1/delete")
assert resp.status_code == 200
assert b"Confirm Delete" in resp.data
assert b"TST-01" in resp.data
assert b"BrandX 001" in resp.data
def test_delete_battery(seeded_client):
@@ -192,6 +192,108 @@ def test_delete_battery(seeded_client):
assert resp.status_code == 404
# ------------------------------------------------------------------ #
# Battery — bulk actions
# ------------------------------------------------------------------ #
def test_bulk_retire(client):
client.post("/battery/add", data={"brand": "TestBrand", "count": "2"})
resp = client.post("/battery/bulk-action",
data={"battery_ids": ["1", "2"], "action": "retire"},
follow_redirects=True)
assert resp.status_code == 200
assert b"retired" in client.get("/battery/1").data.lower()
assert b"retired" in client.get("/battery/2").data.lower()
def test_bulk_delete(client):
client.post("/battery/add", data={"brand": "TestBrand", "count": "2"})
resp = client.post("/battery/bulk-action",
data={"battery_ids": ["1", "2"], "action": "delete"},
follow_redirects=True)
assert resp.status_code == 200
assert client.get("/battery/1").status_code == 404
assert client.get("/battery/2").status_code == 404
def test_bulk_unassign(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "2"})
client.post("/battery/add", data={"brand": "TestBrand", "count": "2"})
client.post("/device/1/install", data={"brand[]": "TestBrand", "qty[]": "2"})
resp = client.post("/battery/bulk-action",
data={"battery_ids": ["1", "2"], "action": "unassign"},
follow_redirects=True)
assert resp.status_code == 200
assert b"available" in client.get("/battery/1").data.lower()
def test_bulk_set_brand(client):
client.post("/battery/add", data={"brand": "OldBrand", "count": "2"})
resp = client.post("/battery/bulk-action",
data={"battery_ids": ["1", "2"], "action": "set_brand",
"new_brand": "NewBrand"},
follow_redirects=True)
assert resp.status_code == 200
assert b"NewBrand" in client.get("/battery/1").data
def test_bulk_action_no_selection(client):
client.post("/battery/add", data={"brand": "TestBrand", "count": "1"})
resp = client.post("/battery/bulk-action",
data={"action": "retire"},
follow_redirects=True)
assert resp.status_code == 200
assert b"No batteries selected" in resp.data
# ------------------------------------------------------------------ #
# Device — install batteries
# ------------------------------------------------------------------ #
def test_device_install_autoselects(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "2"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "3"})
resp = client.post("/device/1/install",
data={"brand[]": "Eneloop", "qty[]": "2"},
follow_redirects=True)
assert resp.status_code == 200
# Device detail should show 2 installed batteries
assert b"Eneloop" in resp.data
assert b"2 / 2" in resp.data
def test_device_install_mixed_brands(client):
client.post("/device/add", data={"name": "Remote", "battery_slots": "4"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
client.post("/battery/add", data={"brand": "Energizer", "count": "2"})
resp = client.post("/device/1/install",
data={"brand[]": ["Eneloop", "Energizer"], "qty[]": ["2", "2"]},
follow_redirects=True)
assert resp.status_code == 200
assert b"Eneloop" in resp.data
assert b"Energizer" in resp.data
def test_device_install_insufficient_batteries(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "4"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
resp = client.post("/device/1/install",
data={"brand[]": "Eneloop", "qty[]": "2"},
follow_redirects=True)
assert resp.status_code == 200
assert b"available" in resp.data.lower()
def test_device_install_over_capacity(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "3"})
resp = client.post("/device/1/install",
data={"brand[]": "Eneloop", "qty[]": "3"},
follow_redirects=True)
assert resp.status_code == 200
assert b"slot" in resp.data.lower()
# ------------------------------------------------------------------ #
# Device — list
# ------------------------------------------------------------------ #
@@ -248,12 +350,9 @@ def test_device_detail_not_found(client):
# ------------------------------------------------------------------ #
def test_delete_device_frees_batteries(seeded_client):
# Assign TST-01 to Device A
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
# Delete Device A
resp = seeded_client.post("/device/1/delete", follow_redirects=True)
assert resp.status_code == 200
# TST-01 should now be available
resp2 = seeded_client.get("/battery/1")
assert b"available" in resp2.data.lower()
@@ -268,14 +367,12 @@ def test_delete_device_removed(seeded_client):
# Full round-trip
# ------------------------------------------------------------------ #
def test_add_assign_delete_battery(client):
# Add a device
def test_add_install_delete_battery(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
# Add a battery
client.post("/battery/add", data={"label": "RT-01", "brand": "AcmeBrand", "status": "available"})
# Assign it
resp = client.post("/battery/1/assign", data={"device_id": "1"}, follow_redirects=True)
client.post("/battery/add", data={"brand": "AcmeBrand", "count": "1"})
resp = client.post("/device/1/install",
data={"brand[]": "AcmeBrand", "qty[]": "1"},
follow_redirects=True)
assert b"Gadget" in resp.data
# Delete it
client.post("/battery/1/delete")
assert client.get("/battery/1").status_code == 404