""" 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: BrandX 001 (id=1, available), BrandY 001 (id=2, available), BrandX 002 (id=3, retired) """ import pytest # ------------------------------------------------------------------ # # Helpers # ------------------------------------------------------------------ # def get_location(resp): """Return the redirect Location header (without host).""" loc = resp.headers.get("Location", "") if loc.startswith("http"): from urllib.parse import urlparse loc = urlparse(loc).path return loc def follow(client, resp): """Follow a single redirect.""" return client.get(get_location(resp)) # ------------------------------------------------------------------ # # Dashboard # ------------------------------------------------------------------ # def test_dashboard_loads(seeded_client): resp = seeded_client.get("/") assert resp.status_code == 200 assert b"BrandX 001" in resp.data assert b"BrandY 001" in resp.data assert b"BrandX 002" in resp.data # ------------------------------------------------------------------ # # Battery add — label preview data # ------------------------------------------------------------------ # def test_battery_add_get_renders(client): resp = client.get("/battery/add") assert resp.status_code == 200 assert b"prefixMaxNums" in resp.data def test_battery_add_brand_counts_reflects_existing(seeded_client): # seeded_client has BrandX x2, BrandY x1 resp = seeded_client.get("/battery/add") assert resp.status_code == 200 assert b'"BrandX": 2' in resp.data or b'"BrandX":2' in resp.data assert b'"BrandY": 1' in resp.data or b'"BrandY":1' in resp.data # ------------------------------------------------------------------ # # Battery — bulk add # ------------------------------------------------------------------ # def test_bulk_add_batteries(client): resp = client.post("/battery/add", data={"brand": "Eneloop", "count": "3"}, follow_redirects=True) assert resp.status_code == 200 assert b"Eneloop 001" in resp.data assert b"Eneloop 002" in resp.data assert b"Eneloop 003" in resp.data 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"required" in resp.data.lower() # ------------------------------------------------------------------ # # Battery — detail # ------------------------------------------------------------------ # def test_battery_detail(seeded_client): resp = seeded_client.get("/battery/1") assert resp.status_code == 200 assert b"BrandX 001" in resp.data assert b"BrandX" in resp.data def test_battery_detail_not_found(client): resp = client.get("/battery/9999") assert resp.status_code == 404 # ------------------------------------------------------------------ # # Battery — edit notes # ------------------------------------------------------------------ # def test_edit_notes(seeded_client): seeded_client.post("/battery/1/edit-details", data={"notes": "test note here"}) resp = seeded_client.get("/battery/1") assert b"test note here" in resp.data # ------------------------------------------------------------------ # # Battery — assign (per-battery, kept for special cases) # ------------------------------------------------------------------ # def test_edit_details_percentage_out_of_range_rejected(seeded_client): for bad in ("150", "-5"): resp = seeded_client.post("/battery/1/edit-details", data={"battery_percentage": bad}, follow_redirects=True) assert b"between 0 and 100" in resp.data data = _json.loads(seeded_client.get("/export/all.json").data) bat = next(b for b in data["batteries"] if b["id"] == 1) assert bat["battery_percentage"] is None assert data["pct_logs"] == [] def test_edit_details_percentage_valid(seeded_client): seeded_client.post("/battery/1/edit-details", data={"battery_percentage": "100"}) data = _json.loads(seeded_client.get("/export/all.json").data) bat = next(b for b in data["batteries"] if b["id"] == 1) assert bat["battery_percentage"] == 100 assert any(l["battery_id"] == 1 and l["source"] == "manual" for l in data["pct_logs"]) def test_assign_battery(seeded_client): resp = seeded_client.post("/battery/1/assign", data={"device_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"Device A" in resp.data def test_assign_retired_battery_blocked(seeded_client): # id=3 is retired resp = seeded_client.post("/battery/3/assign", data={"device_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"retired" in resp.data.lower() def test_assign_over_capacity_blocked(seeded_client): # Fill Device B (1 slot) with id=1 seeded_client.post("/battery/1/assign", data={"device_id": "2"}) # Try to assign id=2 to the same full device resp = seeded_client.post("/battery/2/assign", data={"device_id": "2"}, follow_redirects=True) assert resp.status_code == 200 assert b"full" in resp.data.lower() def test_brand_mix_warning(seeded_client): # Assign BrandX 001 (id=1) to Device A seeded_client.post("/battery/1/assign", data={"device_id": "1"}) # 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 assert b"mixing" in resp.data.lower() or b"mix" in resp.data.lower() or b"brand" in resp.data.lower() # ------------------------------------------------------------------ # # Battery — unassign # ------------------------------------------------------------------ # 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 resp2 = seeded_client.get("/battery/1") assert b"available" in resp2.data.lower() def test_unassign_next_honors_local_path(seeded_client): seeded_client.post("/battery/1/assign", data={"device_id": "1"}) resp = seeded_client.post("/battery/1/unassign", data={"next": "/device/1"}) assert get_location(resp) == "/device/1" def test_unassign_next_rejects_external_redirect(seeded_client): seeded_client.post("/battery/1/assign", data={"device_id": "1"}) resp = seeded_client.post("/battery/1/unassign", data={"next": "//evil.com/phish"}) assert resp.headers["Location"] == "/" def test_unassign_all_next_rejects_external_redirect(seeded_client): seeded_client.post("/battery/1/assign", data={"device_id": "1"}) resp = seeded_client.post("/device/1/unassign-all", data={"next": "//evil.com"}) assert resp.headers["Location"] == "/device/" # ------------------------------------------------------------------ # # Battery — retire # ------------------------------------------------------------------ # def test_retire_battery(seeded_client): resp = seeded_client.post("/battery/1/retire", follow_redirects=True) assert resp.status_code == 200 resp2 = seeded_client.get("/battery/1") assert b"retired" in resp2.data.lower() def test_retire_clears_device(seeded_client): seeded_client.post("/battery/1/assign", data={"device_id": "1"}) seeded_client.post("/battery/1/retire") resp = seeded_client.get("/battery/1") assert b"None" in resp.data or b"retired" in resp.data.lower() def test_retire_already_retired(seeded_client): # 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() # ------------------------------------------------------------------ # # Battery — delete # ------------------------------------------------------------------ # 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"BrandX 001" in resp.data def test_delete_battery(seeded_client): seeded_client.post("/battery/1/delete") resp = seeded_client.get("/battery/1") 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", "battery_size": "AA"}) 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", "battery_size": "AA"}) 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", "battery_size": "AA"}) 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", "battery_size": "AA"}) 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", "battery_size": "AA"}) 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() def test_bulk_install_device(client): """Select multiple available batteries and install them into a device.""" client.post("/device/add", data={"name": "Box", "battery_slots": "3", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Eneloop", "count": "2"}) resp = client.post("/battery/bulk-action", data={"battery_ids": ["1", "2"], "action": "install_device", "device_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"Installed 2" in resp.data def test_bulk_install_device_over_capacity(client): """Bulk install is blocked when device lacks free slots.""" client.post("/device/add", data={"name": "Box", "battery_slots": "1", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Eneloop", "count": "2"}) resp = client.post("/battery/bulk-action", data={"battery_ids": ["1", "2"], "action": "install_device", "device_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"slot" in resp.data.lower() def test_bulk_install_device_moves_installed_battery(client): """Bulk install moves a battery already installed in another device.""" client.post("/device/add", data={"name": "Box A", "battery_slots": "2", "battery_size": "AA"}) client.post("/device/add", data={"name": "Box B", "battery_slots": "2", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Eneloop", "count": "1"}) client.post("/battery/1/assign", data={"device_id": "1"}) resp = client.post("/battery/bulk-action", data={"battery_ids": ["1"], "action": "install_device", "device_id": "2"}, follow_redirects=True) assert resp.status_code == 200 assert b"Installed 1" in resp.data assert b"Box B" in client.get("/battery/1").data # ------------------------------------------------------------------ # # Dashboard — quick-assign # ------------------------------------------------------------------ # def test_dashboard_quick_assign(client): """Quick-assign from dashboard (battery_assign POST) succeeds.""" client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Eneloop", "count": "1"}) resp = client.post("/battery/1/assign", data={"device_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"Box" in resp.data def test_dashboard_quick_assign_full_device_blocked(client): """Quick-assign from dashboard to a full device is blocked.""" client.post("/device/add", data={"name": "Box", "battery_slots": "1", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Eneloop", "count": "2"}) client.post("/battery/1/assign", data={"device_id": "1"}) # fills the slot resp = client.post("/battery/2/assign", data={"device_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"full" in resp.data.lower() def test_quick_assign_reassign_battery(client): """Quick-assign moves a battery that is already installed in another device.""" client.post("/device/add", data={"name": "Box A", "battery_slots": "2", "battery_size": "AA"}) client.post("/device/add", data={"name": "Box B", "battery_slots": "2", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Eneloop", "count": "1"}) client.post("/battery/1/assign", data={"device_id": "1"}) # install into Box A resp = client.post("/battery/1/assign", data={"device_id": "2"}, follow_redirects=True) assert resp.status_code == 200 assert b"Box B" in resp.data def test_quick_assign_retired_battery_blocked(client): """battery_assign POST for a retired battery is blocked.""" client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Eneloop", "count": "1"}) client.post("/battery/1/retire") resp = client.post("/battery/1/assign", data={"device_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"retired" in resp.data.lower() # ------------------------------------------------------------------ # # Device — install-one (specific battery) # ------------------------------------------------------------------ # def test_install_one_specific_battery(client): """device_install_one installs a chosen battery.""" client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Eneloop", "count": "1"}) resp = client.post("/device/1/install-one", data={"battery_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"Eneloop 001" in resp.data def test_install_one_over_capacity_blocked(client): """device_install_one to a full device is blocked.""" client.post("/device/add", data={"name": "Box", "battery_slots": "1", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Eneloop", "count": "2"}) client.post("/device/1/install-one", data={"battery_id": "1"}) # fills slot resp = client.post("/device/1/install-one", data={"battery_id": "2"}, follow_redirects=True) assert resp.status_code == 200 assert b"full" in resp.data.lower() # ------------------------------------------------------------------ # # Device — list # ------------------------------------------------------------------ # def test_device_list(seeded_client): resp = seeded_client.get("/device/") assert resp.status_code == 200 assert b"Device A" in resp.data assert b"Device B" in resp.data # ------------------------------------------------------------------ # # Device — add # ------------------------------------------------------------------ # def test_add_device(client): resp = client.post("/device/add", data={"name": "My Gadget", "battery_slots": "3", "battery_size": "AA"}, follow_redirects=True) assert resp.status_code == 200 assert b"My Gadget" in resp.data def test_add_device_duplicate_name(seeded_client): resp = seeded_client.post("/device/add", data={"name": "Device A", "battery_slots": "2", "battery_size": "AA"}) assert resp.status_code == 400 assert b"already exists" in resp.data def test_add_device_missing_name(client): resp = client.post("/device/add", data={"name": "", "battery_slots": "1"}) assert resp.status_code == 400 # ------------------------------------------------------------------ # # Device — detail # ------------------------------------------------------------------ # def test_device_detail(seeded_client): resp = seeded_client.get("/device/1") assert resp.status_code == 200 assert b"Device A" in resp.data assert b"2" in resp.data # battery_slots def test_device_detail_not_found(client): resp = client.get("/device/9999") assert resp.status_code == 404 # ------------------------------------------------------------------ # # Device — delete # ------------------------------------------------------------------ # def test_delete_device_frees_batteries(seeded_client): seeded_client.post("/battery/1/assign", data={"device_id": "1"}) resp = seeded_client.post("/device/1/delete", follow_redirects=True) assert resp.status_code == 200 resp2 = seeded_client.get("/battery/1") assert b"available" in resp2.data.lower() def test_delete_device_removed(seeded_client): seeded_client.post("/device/1/delete") resp = seeded_client.get("/device/1") assert resp.status_code == 404 # ------------------------------------------------------------------ # # Full round-trip # ------------------------------------------------------------------ # def test_add_device_with_type(client): resp = client.post("/device/add", data={"name": "TV Remote", "battery_slots": "2", "battery_size": "AA", "device_type": "Remote Control"}, follow_redirects=True) assert resp.status_code == 200 assert b"TV Remote" in resp.data def test_device_detail_shows_type(client): client.post("/device/add", data={"name": "Torch", "battery_slots": "1", "battery_size": "AA", "device_type": "Flashlight"}) resp = client.get("/device/1") assert resp.status_code == 200 assert b"Flashlight" in resp.data def test_edit_device_type(client): client.post("/device/add", data={"name": "Torch", "battery_slots": "1", "battery_size": "AA"}) resp = client.post("/device/1/edit", data={"name": "Torch", "battery_slots": "1", "device_type": "Flashlight"}, follow_redirects=True) assert resp.status_code == 200 assert b"Flashlight" in resp.data def test_add_device_with_location(client): resp = client.post("/device/add", data={"name": "Bedroom Remote", "battery_slots": "2", "battery_size": "AA", "location": "Bedroom"}, follow_redirects=True) assert resp.status_code == 200 assert b"Bedroom Remote" in resp.data resp = client.get("/device/1") assert b"Bedroom" in resp.data def test_edit_device_location(client): client.post("/device/add", data={"name": "Living Room Clock", "battery_slots": "1", "battery_size": "AA"}) resp = client.post("/device/1/edit", data={"name": "Living Room Clock", "battery_slots": "1", "location": "Living Room"}, follow_redirects=True) assert resp.status_code == 200 assert b"Living Room" in resp.data def test_add_capacity_test(client): client.post("/battery/add", data={"brand": "Eneloop", "count": "1"}) resp = client.post("/battery/1/capacity-test/add", data={"tested_capacity_mah": "1900", "tested_date": "2026-01-01"}, follow_redirects=True) assert resp.status_code == 200 assert b"1900" in resp.data def test_capacity_test_syncs_battery(client): """Adding a test updates battery.tested_capacity_mah.""" client.post("/battery/add", data={"brand": "Eneloop", "count": "1"}) client.post("/battery/1/capacity-test/add", data={"tested_capacity_mah": "1800", "tested_date": "2026-01-01"}) resp = client.get("/battery/1") assert b"1800" in resp.data def test_delete_capacity_test(client): client.post("/battery/add", data={"brand": "Eneloop", "count": "1"}) client.post("/battery/1/capacity-test/add", data={"tested_capacity_mah": "1900", "tested_date": "2026-01-01"}) resp = client.post("/battery/1/capacity-test/1/delete", follow_redirects=True) assert resp.status_code == 200 assert b"No test records" in resp.data def test_add_install_delete_battery(client): client.post("/device/add", data={"name": "Gadget", "battery_slots": "1", "battery_size": "AA"}) 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 client.post("/battery/1/delete") assert client.get("/battery/1").status_code == 404 # ------------------------------------------------------------------ # # Device — battery_size # ------------------------------------------------------------------ # def test_add_device_with_battery_size(client): """Device with battery_size shows it on detail page.""" resp = client.post("/device/add", data={"name": "AA Remote", "battery_slots": "2", "battery_size": "AA"}, follow_redirects=True) assert resp.status_code == 200 resp2 = client.get("/device/1") assert b"AA" in resp2.data def test_install_one_filters_by_size(client): """Single-battery install dropdown excludes batteries with wrong size.""" client.post("/device/add", data={"name": "AA Device", "battery_slots": "2", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Eneloop", "count": "1"}) # id=1 client.post("/battery/1/edit-details", data={"size": "AA"}) client.post("/battery/add", data={"brand": "Energizer", "count": "1"}) # id=2 client.post("/battery/2/edit-details", data={"size": "AAA"}) resp = client.get("/device/1") assert resp.status_code == 200 # AA battery should appear in the dropdown, AAA should not assert b"Eneloop 001" in resp.data assert b"Energizer 001" not in resp.data def test_bulk_install_filters_by_size(client): """Bulk install with a brand that only has wrong-size batteries fails with 0 available.""" client.post("/device/add", data={"name": "AA Device", "battery_slots": "2", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Energizer", "count": "2"}) # Set both batteries to AAA (explicitly incompatible) client.post("/battery/1/edit-details", data={"size": "AAA"}) client.post("/battery/2/edit-details", data={"size": "AAA"}) resp = client.post("/device/1/install", data={"brand[]": "Energizer", "qty[]": "1"}, follow_redirects=True) assert resp.status_code == 200 # ------------------------------------------------------------------ # # Import # ------------------------------------------------------------------ # import io import json as _json def _make_import_payload(devices=None, batteries=None, charge_logs=None, capacity_tests=None, pct_logs=None): data = { "exported_at": "2026-01-01T00:00:00", "devices": devices or [], "batteries": batteries or [], "charge_logs": charge_logs or [], "capacity_tests": capacity_tests or [], "pct_logs": pct_logs or [], } return (io.BytesIO(_json.dumps(data).encode()), "export.json") def _post_import(client, payload_tuple): buf, fname = payload_tuple return client.post( "/import", data={"file": (buf, fname)}, content_type="multipart/form-data", ) def _bat(id, label, brand="Brand", status="available", device_id=None, **kw): return {"id": id, "label": label, "brand": brand, "status": status, "device_id": device_id, "size": None, "chemistry": None, "capacity_mah": None, "tested_capacity_mah": None, "tested_date": None, "charge_cycles": None, "purchase_date": None, "storage_location": None, "battery_percentage": None, "notes": None, **kw} def _dev(id, name, battery_slots=2, battery_size="AA", **kw): return {"id": id, "name": name, "battery_slots": battery_slots, "battery_size": battery_size, "device_type": None, "location": None, "ha_entity_id": None, "notes": None, **kw} def test_import_page_loads(client): resp = client.get("/import") assert resp.status_code == 200 assert b"Import" in resp.data assert b'enctype="multipart/form-data"' in resp.data def test_import_no_file_returns_400(client): resp = client.post("/import", data={}, content_type="multipart/form-data") assert resp.status_code == 400 def test_import_invalid_json_returns_400(client): buf = io.BytesIO(b"this is not json") resp = client.post("/import", data={"file": (buf, "bad.json")}, content_type="multipart/form-data") assert resp.status_code == 400 assert b"Invalid JSON" in resp.data def test_import_missing_required_keys_returns_400(client): buf = io.BytesIO(_json.dumps({"exported_at": "2026-01-01"}).encode()) resp = client.post("/import", data={"file": (buf, "bad.json")}, content_type="multipart/form-data") assert resp.status_code == 400 def test_import_creates_devices_and_batteries(client): payload = _make_import_payload( devices=[_dev(1, "RC Car")], batteries=[_bat(1, "Eneloop 001", brand="Eneloop")], ) resp = _post_import(client, payload) assert resp.status_code == 200 assert b"Import Results" in resp.data dash = client.get("/") assert b"Eneloop 001" in dash.data assert b"RC Car" in dash.data def test_import_results_show_correct_counts(client): payload = _make_import_payload( devices=[_dev(10, "DevX")], batteries=[_bat(10, "BrandX 001"), _bat(11, "BrandX 002")], ) resp = _post_import(client, payload) assert resp.status_code == 200 # devices_created=1, batteries_created=2 appear in the results table assert b"Import Results" in resp.data def test_import_charge_logs_remapped_correctly(client): payload = _make_import_payload( batteries=[_bat(99, "Eneloop 001", brand="Eneloop")], charge_logs=[{"id": 1, "battery_id": 99, "battery_label": "Eneloop 001", "charged_date": "2026-01-15", "increment_cycles": 1, "notes": None}], ) resp = _post_import(client, payload) assert resp.status_code == 200 detail = client.get("/battery/1") assert b"2026-01-15" in detail.data def test_import_capacity_tests_remapped(client): payload = _make_import_payload( batteries=[_bat(5, "Test 001", capacity_mah=2000)], capacity_tests=[{"id": 1, "battery_id": 5, "battery_label": "Test 001", "tested_capacity_mah": 1850, "tested_date": "2026-02-01", "notes": None}], ) resp = _post_import(client, payload) assert resp.status_code == 200 detail = client.get("/battery/1") assert b"1850" in detail.data def test_import_skips_existing_device(client): client.post("/device/add", data={"name": "RC Car", "battery_slots": "2", "battery_size": "AA"}) payload = _make_import_payload( devices=[_dev(1, "RC Car", battery_slots=4)], ) resp = _post_import(client, payload) assert resp.status_code == 200 # Original 2-slot device not overwritten detail = client.get("/device/1") assert b"RC Car" in detail.data def test_import_skips_existing_battery(client): client.post("/battery/add", data={"brand": "Eneloop", "count": "1"}) payload = _make_import_payload( batteries=[_bat(999, "Eneloop 001", brand="Eneloop", status="retired")], ) resp = _post_import(client, payload) assert resp.status_code == 200 detail = client.get("/battery/1") assert b"available" in detail.data.lower() def test_import_log_skipped_when_battery_not_in_map(client): payload = _make_import_payload( batteries=[], charge_logs=[{"id": 1, "battery_id": 42, "battery_label": "Ghost", "charged_date": "2026-01-01", "increment_cycles": 1, "notes": None}], ) resp = _post_import(client, payload) assert resp.status_code == 200 assert b"Import Results" in resp.data def test_import_battery_gets_installed_when_device_maps(client): payload = _make_import_payload( devices=[_dev(7, "My Device")], batteries=[_bat(7, "Linked 001", status="installed", device_id=7)], ) resp = _post_import(client, payload) assert resp.status_code == 200 detail = client.get("/battery/1") assert b"installed" in detail.data.lower() assert b"My Device" in detail.data def test_import_battery_retired_preserved_when_no_device(client): payload = _make_import_payload( batteries=[_bat(3, "Old 001", status="retired")], ) resp = _post_import(client, payload) assert resp.status_code == 200 detail = client.get("/battery/1") assert b"retired" in detail.data.lower() def test_import_battery_device_id_unknown_becomes_available(client): payload = _make_import_payload( devices=[], batteries=[_bat(5, "Orphan 001", status="installed", device_id=99)], ) resp = _post_import(client, payload) assert resp.status_code == 200 detail = client.get("/battery/1") assert b"available" in detail.data.lower() def test_full_roundtrip_export_import(client): client.post("/device/add", data={"name": "Router", "battery_slots": "1", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Eneloop", "count": "1"}) client.post("/battery/1/assign", data={"device_id": "1"}) export_resp = client.get("/export/all.json") assert export_resp.status_code == 200 buf = io.BytesIO(export_resp.data) resp = client.post("/import", data={"file": (buf, "export.json")}, content_type="multipart/form-data") assert resp.status_code == 200 assert b"Import Results" in resp.data def test_export_json_includes_parent_fields(client): _setup_rc_car(client) data = _json.loads(client.get("/export/all.json").data) devs = {d["name"]: d for d in data["devices"]} assert devs["RC Car Set"]["parent_id"] is None assert devs["Remote"]["parent_id"] == devs["RC Car Set"]["id"] assert devs["Remote"]["parent_name"] == "RC Car Set" def test_import_rebuilds_hierarchy(client): payload = _make_import_payload(devices=[ _dev(10, "Hub", battery_slots=0, battery_size=None), _dev(11, "Probe", parent_id=10), ]) resp = _post_import(client, payload) assert resp.status_code == 200 data = _json.loads(client.get("/export/all.json").data) devs = {d["name"]: d for d in data["devices"]} assert devs["Probe"]["parent_id"] == devs["Hub"]["id"] assert devs["Probe"]["parent_name"] == "Hub" # parent's 0 slots survive the import (previously coerced to 1) assert devs["Hub"]["battery_slots"] == 0 def test_import_child_listed_before_parent(client): payload = _make_import_payload(devices=[ _dev(11, "Probe", parent_id=10), _dev(10, "Hub", battery_slots=0, battery_size=None), ]) resp = _post_import(client, payload) assert resp.status_code == 200 data = _json.loads(client.get("/export/all.json").data) devs = {d["name"]: d for d in data["devices"]} assert devs["Probe"]["parent_id"] == devs["Hub"]["id"] def test_import_subcomponent_missing_parent_becomes_top_level(client): payload = _make_import_payload(devices=[_dev(11, "Probe", parent_id=99)]) resp = _post_import(client, payload) assert resp.status_code == 200 data = _json.loads(client.get("/export/all.json").data) devs = {d["name"]: d for d in data["devices"]} assert devs["Probe"]["parent_id"] is None def test_import_empty_battery_size_stored_as_null(client): payload = _make_import_payload(devices=[_dev(10, "NoSize", battery_size="")]) resp = _post_import(client, payload) assert resp.status_code == 200 data = _json.loads(client.get("/export/all.json").data) devs = {d["name"]: d for d in data["devices"]} assert devs["NoSize"]["battery_size"] is None def test_roundtrip_import_preserves_hierarchy(client): _setup_rc_car(client) data = _json.loads(client.get("/export/all.json").data) # rename everything so the import creates rows instead of skipping for d in data["devices"]: d["name"] = d["name"] + " v2" buf = io.BytesIO(_json.dumps(data).encode()) resp = client.post("/import", data={"file": (buf, "export.json")}, content_type="multipart/form-data") assert resp.status_code == 200 data2 = _json.loads(client.get("/export/all.json").data) devs = {d["name"]: d for d in data2["devices"]} assert devs["Remote v2"]["parent_id"] == devs["RC Car Set v2"]["id"] assert devs["Car v2"]["parent_id"] == devs["RC Car Set v2"]["id"] def test_device_detail_unassign_all(seeded_client): client = seeded_client # install battery 1 into device 1 (2-slot AA device) client.post("/device/1/install-one", data={"battery_id": "1"}) # unassign all from device detail, redirecting back to device detail resp = client.post( "/device/1/unassign-all", data={"next": "/device/1"}, follow_redirects=True, ) assert resp.status_code == 200 assert b"Unassigned" in resp.data # device detail should now show 0 installed assert b"0 / 2" in resp.data def test_device_install_batch(seeded_client): client = seeded_client # batteries 1 (BrandX AA) and 2 (BrandY AA) are available; device 1 has 2 slots resp = client.post( "/device/1/install-batch", data={"battery_ids": ["1", "2"]}, follow_redirects=True, ) assert resp.status_code == 200 assert b"Installed 2" in resp.data assert b"2 / 2" in resp.data def test_device_charge_all(seeded_client): client = seeded_client # install battery 1 into device 1 client.post("/device/1/install-one", data={"battery_id": "1"}) resp = client.post( "/device/1/charge-all", data={"charged_date": "2025-01-01", "increment_cycles": "1"}, follow_redirects=True, ) assert resp.status_code == 200 assert b"Logged charge for 1 battery" in resp.data assert b"+cycles" in resp.data def test_device_charge_all_no_installed(seeded_client): client = seeded_client # device 1 has no batteries installed yet resp = client.post( "/device/1/charge-all", data={"charged_date": "2025-01-01"}, follow_redirects=True, ) assert resp.status_code == 200 assert b"No installed batteries" in resp.data # ------------------------------------------------------------------ # # Sub-components (self-referential device hierarchy) # ------------------------------------------------------------------ # def _setup_rc_car(client): """Create RC Car Set with Remote and Car sub-components and 6 AA batteries.""" client.post("/device/add", data={"name": "RC Car Set", "battery_slots": "0", "battery_size": ""}) # id=1 above; add sub-components with parent_id=1 client.post("/device/add", data={"name": "Remote", "battery_slots": "2", "battery_size": "AA", "parent_id": "1"}) client.post("/device/add", data={"name": "Car", "battery_slots": "4", "battery_size": "AA", "parent_id": "1"}) for i in range(1, 7): client.post("/battery/add", data={"brand": "Eneloop", "label": f"E{i:03d}", "size": "AA"}) def test_add_subcomponent(client): _setup_rc_car(client) # Parent detail shows sub-components resp = client.get("/device/1") assert resp.status_code == 200 assert b"Remote" in resp.data assert b"Car" in resp.data # Child detail shows breadcrumb back to parent resp = client.get("/device/2") assert resp.status_code == 200 assert b"RC Car Set" in resp.data assert b"Remote" in resp.data def test_device_with_children_cannot_become_subcomponent(client): _setup_rc_car(client) # Garage is parent-eligible (top-level, 0 slots, no size) client.post("/device/add", data={"name": "Garage", "battery_slots": "0", "battery_size": ""}) resp = client.post("/device/1/edit", data={"name": "RC Car Set", "battery_slots": "0", "parent_id": "4"}, follow_redirects=True) assert b"cannot itself become a sub-component" in resp.data data = _json.loads(client.get("/export/all.json").data) devs = {d["name"]: d for d in data["devices"]} assert devs["RC Car Set"]["parent_id"] is None def test_subcomponent_prevents_deep_nesting(client): _setup_rc_car(client) # id=2 is "Remote", which already has parent_id=1 resp = client.post("/device/add", data={"name": "Button", "battery_slots": "1", "battery_size": "AA", "parent_id": "2"}) assert resp.status_code == 400 assert b"one level" in resp.data.lower() or b"nest" in resp.data.lower() def test_subcomponent_install_batteries(client): _setup_rc_car(client) # Install 2 batteries into Remote (device id=2) resp = client.post("/device/2/install-one", data={"battery_id": "1"}, follow_redirects=True) assert resp.status_code == 200 resp = client.post("/device/2/install-one", data={"battery_id": "2"}, follow_redirects=True) assert resp.status_code == 200 # Remote is now full (2/2); a third battery should be blocked resp = client.post("/device/2/install-one", data={"battery_id": "3"}, follow_redirects=True) assert resp.status_code == 200 assert b"full" in resp.data.lower() def test_dashboard_shows_parent_slash_child(client): _setup_rc_car(client) client.post("/device/2/install-one", data={"battery_id": "1"}) resp = client.get("/", follow_redirects=True) assert resp.status_code == 200 assert b"RC Car Set" in resp.data assert b"Remote" in resp.data def test_delete_parent_promotes_children(client): _setup_rc_car(client) client.post("/device/1/delete", follow_redirects=True) # Child devices still exist resp = client.get("/device/2") assert resp.status_code == 200 resp = client.get("/device/3") assert resp.status_code == 200 def test_device_without_parent_unchanged(client): client.post("/device/add", data={"name": "Torch", "battery_slots": "2", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Eneloop", "count": "1"}) resp = client.post("/device/1/install-one", data={"battery_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"1 / 2" in resp.data def test_subcomponent_ha_entity_id(client): client.post("/device/add", data={"name": "Hub", "battery_slots": "0", "battery_size": ""}) resp = client.post( "/device/add", data={"name": "Sensor A", "battery_slots": "1", "battery_size": "AA", "parent_id": "1", "ha_entity_id": "sensor.sensor_a_battery"}, follow_redirects=True, ) # Redirects to parent detail on success assert resp.status_code == 200 assert b"Sensor A" in resp.data # Child detail shows parent breadcrumb resp = client.get("/device/2") assert resp.status_code == 200 assert b"Hub" in resp.data # ------------------------------------------------------------------ # # Sub-component hierarchy — parent_key, null type/location, list hiding # ------------------------------------------------------------------ # def _setup_parent_with_two_children(client): """Hub (top-level, Sensor type, Living Room) with Sensor A and Sensor B.""" client.post("/device/add", data={ "name": "Hub", "battery_slots": "0", "battery_size": "", "device_type": "Sensor", "location": "Living Room", }, follow_redirects=True) client.post("/device/add", data={ "name": "Sensor A", "battery_slots": "1", "battery_size": "AA", "parent_id": "1", }, follow_redirects=True) client.post("/device/add", data={ "name": "Sensor B", "battery_slots": "1", "battery_size": "AA", "parent_id": "1", }, follow_redirects=True) def test_device_list_hides_subcomponents(client): _setup_parent_with_two_children(client) resp = client.get("/device/") assert resp.status_code == 200 assert b"Hub" in resp.data assert b"Sensor A" not in resp.data assert b"Sensor B" not in resp.data def test_subcomponent_type_location_null(client): _setup_parent_with_two_children(client) # Sub-component detail shows inherited type/location from parent resp = client.get("/device/2") assert resp.status_code == 200 assert b"Sensor" in resp.data assert b"Living Room" in resp.data assert b"inherited" in resp.data # Submitting type/location in edit for sub-component should have no effect client.post("/device/2/edit", data={ "name": "Sensor A", "battery_slots": "1", "device_type": "Remote Control", "location": "Bedroom", "parent_id": "1", # keep sub-component relationship }) resp2 = client.get("/device/2") assert b"Remote Control" not in resp2.data assert b"Bedroom" not in resp2.data def test_subcomponent_name_unique_per_parent(client): _setup_parent_with_two_children(client) # Duplicate top-level name → 400 resp = client.post("/device/add", data={ "name": "Hub", "battery_slots": "0", "battery_size": "", }) assert resp.status_code == 400 assert b"already exists" in resp.data # Duplicate child name within same parent → 400 resp = client.post("/device/add", data={ "name": "Sensor A", "battery_slots": "1", "battery_size": "AA", "parent_id": "1", }) assert resp.status_code == 400 assert b"already exists" in resp.data # Same child name under a DIFFERENT parent → success client.post("/device/add", data={ "name": "Hub 2", "battery_slots": "0", "battery_size": "", }) # id=4 resp = client.post("/device/add", data={ "name": "Sensor A", "battery_slots": "1", "battery_size": "AA", "parent_id": "4", }, follow_redirects=True) assert resp.status_code == 200 def test_parent_battery_summary_flat(client): _setup_parent_with_two_children(client) client.post("/battery/add", data={"brand": "Eneloop", "count": "2"}) # ids 1, 2 client.post("/device/2/install-one", data={"battery_id": "1"}) client.post("/device/3/install-one", data={"battery_id": "2"}) resp = client.get("/device/1") assert resp.status_code == 200 # Flat table has Component column heading assert b"Component" in resp.data # Both batteries appear assert b"Eneloop 001" in resp.data assert b"Eneloop 002" in resp.data # Both child names linked in the component column assert b"Sensor A" in resp.data assert b"Sensor B" in resp.data # Flat table heading appears once (not duplicated per sub-component) assert resp.data.count(b"

Installed Batteries

") == 1 def test_device_charge_all_parent(client): _setup_parent_with_two_children(client) client.post("/battery/add", data={"brand": "Eneloop", "label": "E001", "size": "AA"}) client.post("/battery/add", data={"brand": "Eneloop", "label": "E002", "size": "AA"}) client.post("/device/2/install-one", data={"battery_id": "1"}) client.post("/device/3/install-one", data={"battery_id": "2"}) resp = client.post( "/device/1/charge-all", data={"charged_date": "2025-06-01", "increment_cycles": "1"}, follow_redirects=True, ) assert resp.status_code == 200 assert b"Logged charge for 2 batteri" in resp.data assert b"+cycles" in resp.data def test_device_charge_all_parent_no_installed(client): _setup_parent_with_two_children(client) resp = client.post( "/device/1/charge-all", data={"charged_date": "2025-06-01"}, follow_redirects=True, ) assert resp.status_code == 200 assert b"No installed batteries" in resp.data # ------------------------------------------------------------------ # # Battery — unretire # ------------------------------------------------------------------ # def test_unretire_battery(seeded_client): # id=3 is retired by fixture resp = seeded_client.post("/battery/3/unretire", follow_redirects=True) assert resp.status_code == 200 assert b"now available again" in resp.data assert b"available" in seeded_client.get("/battery/3").data.lower() def test_unretire_not_retired(seeded_client): resp = seeded_client.post("/battery/1/unretire", follow_redirects=True) assert b"is not retired" in resp.data # ------------------------------------------------------------------ # # Logbook # ------------------------------------------------------------------ # def test_battery_logbook_add_and_delete(seeded_client): resp = seeded_client.post("/battery/1/logbook/add", data={"body": "Swapped terminals"}, follow_redirects=True) assert b"Swapped terminals" in resp.data resp = seeded_client.post("/battery/1/logbook/1/delete", follow_redirects=True) assert b"Logbook entry deleted" in resp.data assert b"Swapped terminals" not in seeded_client.get("/battery/1").data def test_battery_logbook_add_empty_body(seeded_client): resp = seeded_client.post("/battery/1/logbook/add", data={"body": " "}, follow_redirects=True) assert b"Entry text is required" in resp.data def test_device_logbook_add_and_delete(seeded_client): resp = seeded_client.post("/device/1/logbook/add", data={"body": "Cleaned contacts"}, follow_redirects=True) assert b"Cleaned contacts" in resp.data resp = seeded_client.post("/device/1/logbook/1/delete", follow_redirects=True) assert b"Logbook entry deleted" in resp.data assert b"Cleaned contacts" not in seeded_client.get("/device/1").data def test_device_logbook_add_empty_body(seeded_client): resp = seeded_client.post("/device/1/logbook/add", data={"body": ""}, follow_redirects=True) assert b"Entry text is required" in resp.data # ------------------------------------------------------------------ # # Charge log — delete # ------------------------------------------------------------------ # def test_charge_log_delete_reverts_cycles(seeded_client): seeded_client.post("/battery/1/charge-log/add", data={"charged_date": "2026-06-01", "increment_cycles": "1"}) data = _json.loads(seeded_client.get("/export/all.json").data) assert next(b for b in data["batteries"] if b["id"] == 1)["charge_cycles"] == 1 log_id = data["charge_logs"][0]["id"] resp = seeded_client.post(f"/battery/1/charge-log/{log_id}/delete", follow_redirects=True) assert b"Charge log entry deleted" in resp.data data = _json.loads(seeded_client.get("/export/all.json").data) assert next(b for b in data["batteries"] if b["id"] == 1)["charge_cycles"] == 0 assert data["charge_logs"] == [] # ------------------------------------------------------------------ # # CSV exports # ------------------------------------------------------------------ # def test_csv_export_routes(seeded_client): expectations = { "/export/batteries.csv": b"label", "/export/devices.csv": b"parent_id,parent_name", "/export/charge-logs.csv": b"charged_date", "/export/capacity-tests.csv": b"tested_capacity_mah", "/export/pct-logs.csv": b"percentage", } for url, marker in expectations.items(): resp = seeded_client.get(url) assert resp.status_code == 200, url assert "text/csv" in resp.content_type, url assert marker in resp.data, url resp = seeded_client.get("/export/csv.zip") assert resp.status_code == 200 assert "zip" in resp.content_type # ------------------------------------------------------------------ # # Sub-component edit — type/location stay NULL # ------------------------------------------------------------------ # def test_subcomponent_edit_resave_keeps_nulls(client): _setup_rc_car(client) # device 2 = Remote, sub-component of 1; re-save smuggling in type/location resp = client.post("/device/2/edit", data={ "name": "Remote", "battery_slots": "2", "parent_id": "1", "device_type": "Toy", "location": "Garage", "battery_size": "AA", }, follow_redirects=True) assert resp.status_code == 200 data = _json.loads(client.get("/export/all.json").data) rem = next(d for d in data["devices"] if d["name"] == "Remote") assert rem["parent_id"] is not None assert rem["device_type"] is None assert rem["location"] is None