""" Tests targeting uncovered lines from coverage analysis. Covers: 404 paths, validation errors, edge cases, model reprs, ha_client/ha_poller branches, export/import edge cases, bulk actions. """ import io import json from unittest.mock import patch, MagicMock # ------------------------------------------------------------------ # # Helpers (mirrors test_acceptance.py) # ------------------------------------------------------------------ # def get_location(resp): loc = resp.headers.get("Location", "") if loc.startswith("http"): from urllib.parse import urlparse loc = urlparse(loc).path return loc def follow(client, resp): return client.get(get_location(resp)) def _make_json(devices=None, batteries=None, charge_logs=None, capacity_tests=None, pct_logs=None): return json.dumps({ "exported_at": "2026-01-01", "devices": devices or [], "batteries": batteries or [], "charge_logs": charge_logs or [], "capacity_tests": capacity_tests or [], "pct_logs": pct_logs or [], }).encode() def _post_import(client, raw_bytes, filename="export.json"): return client.post( "/import", data={"file": (io.BytesIO(raw_bytes), filename)}, content_type="multipart/form-data", ) # ------------------------------------------------------------------ # # _parse_date helper (lines 18, 22-23) # ------------------------------------------------------------------ # def test_parse_date_valid(client): """Valid date passes through battery_add without error.""" client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post( "/battery/1/edit-details", data={"purchase_date": "2026-01-15"}, follow_redirects=True, ) assert resp.status_code == 200 def test_parse_date_invalid_returns_none(client): """Invalid date string is treated as None (no crash, details updated).""" client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post( "/battery/1/edit-details", data={"purchase_date": "not-a-date"}, follow_redirects=True, ) assert resp.status_code == 200 # ------------------------------------------------------------------ # # service_worker route (line 105) # ------------------------------------------------------------------ # def test_service_worker_served(client): resp = client.get("/sw.js") assert resp.status_code in (200, 404) # 404 if file absent; route exists # ------------------------------------------------------------------ # # export page (line 1236) # ------------------------------------------------------------------ # def test_export_page_loads(client): resp = client.get("/export") assert resp.status_code == 200 # ------------------------------------------------------------------ # # battery_add POST — count ValueError and _int ValueError (163-164, 177-178) # ------------------------------------------------------------------ # def test_battery_add_invalid_count_falls_back_to_one(client): """Non-integer count silently falls back to 1.""" resp = client.post("/battery/add", data={"brand": "Eneloop", "count": "abc"}, follow_redirects=True) assert resp.status_code == 200 assert b"Eneloop 001" in resp.data assert b"Eneloop 002" not in resp.data def test_battery_add_invalid_capacity_ignored(client): """Non-integer capacity_mah in add form is stored as None (no crash).""" resp = client.post("/battery/add", data={"brand": "Eneloop", "count": "1", "capacity_mah": "not-a-number"}, follow_redirects=True) assert resp.status_code == 200 detail = client.get("/battery/1") assert detail.status_code == 200 # ------------------------------------------------------------------ # # battery_add GET — size-prefix numbering (lines 216, 221-223) # ------------------------------------------------------------------ # def test_battery_add_get_size_prefix_numbering(client): """GET /battery/add includes size-prefixed prefix_max_nums.""" client.post("/battery/add", data={"brand": "Eneloop", "count": "2", "size": "AA", "include_size_in_label": "on"}) resp = client.get("/battery/add") assert resp.status_code == 200 assert b"prefixMaxNums" in resp.data # ------------------------------------------------------------------ # # battery_edit_details — 404 and _int ValueError (284, 291-292) # ------------------------------------------------------------------ # def test_battery_edit_details_404(client): resp = client.post("/battery/9999/edit-details", data={"notes": "x"}) assert resp.status_code == 404 def test_battery_edit_details_invalid_int_field(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/1/edit-details", data={"capacity_mah": "xyz"}, follow_redirects=True) assert resp.status_code == 200 # ------------------------------------------------------------------ # # battery_capacity_test_add — 404, missing fields, invalid mah (336-349) # ------------------------------------------------------------------ # def test_capacity_test_add_404(client): resp = client.post("/battery/9999/capacity-test/add", data={"tested_capacity_mah": "1000", "tested_date": "2026-01-01"}) assert resp.status_code == 404 def test_capacity_test_add_missing_date(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/1/capacity-test/add", data={"tested_capacity_mah": "1000", "tested_date": ""}, follow_redirects=True) assert resp.status_code == 200 assert b"required" in resp.data.lower() or b"valid date" in resp.data.lower() def test_capacity_test_add_missing_mah(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/1/capacity-test/add", data={"tested_capacity_mah": "", "tested_date": "2026-01-01"}, follow_redirects=True) assert resp.status_code == 200 assert b"required" in resp.data.lower() or b"valid date" in resp.data.lower() def test_capacity_test_add_zero_mah(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/1/capacity-test/add", data={"tested_capacity_mah": "0", "tested_date": "2026-01-01"}, follow_redirects=True) assert resp.status_code == 200 assert b"positive" in resp.data.lower() def test_capacity_test_add_negative_mah(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/1/capacity-test/add", data={"tested_capacity_mah": "-500", "tested_date": "2026-01-01"}, follow_redirects=True) assert resp.status_code == 200 assert b"positive" in resp.data.lower() # ------------------------------------------------------------------ # # battery_capacity_test_delete — 404 (363) # ------------------------------------------------------------------ # def test_capacity_test_delete_404(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/1/capacity-test/9999/delete") assert resp.status_code == 404 def test_capacity_test_delete_wrong_battery(client): """Delete attempt where test belongs to a different battery → 404.""" client.post("/battery/add", data={"brand": "B", "count": "2"}) client.post("/battery/1/capacity-test/add", data={"tested_capacity_mah": "1000", "tested_date": "2026-01-01"}) # test id=1 belongs to battery id=1; try deleting it via battery id=2 resp = client.post("/battery/2/capacity-test/1/delete") assert resp.status_code == 404 # ------------------------------------------------------------------ # # battery_charge_log_add — 404 and missing date (379, 382-383) # ------------------------------------------------------------------ # def test_charge_log_add_404(client): resp = client.post("/battery/9999/charge-log/add", data={"charged_date": "2026-01-01"}) assert resp.status_code == 404 def test_charge_log_add_missing_date(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/1/charge-log/add", data={"charged_date": ""}, follow_redirects=True) assert resp.status_code == 200 assert b"valid date" in resp.data.lower() # ------------------------------------------------------------------ # # battery_charge_log_delete — 404 (396) # ------------------------------------------------------------------ # def test_charge_log_delete_404_battery(client): resp = client.post("/battery/9999/charge-log/1/delete") assert resp.status_code == 404 def test_charge_log_delete_wrong_battery(client): client.post("/battery/add", data={"brand": "B", "count": "2"}) client.post("/battery/1/charge-log/add", data={"charged_date": "2026-01-01"}) # log id=1 belongs to battery 1; try deleting via battery 2 resp = client.post("/battery/2/charge-log/1/delete") assert resp.status_code == 404 # ------------------------------------------------------------------ # # battery_assign — 404, device not found, device has children (412, 425-430) # ------------------------------------------------------------------ # def test_battery_assign_get_404(client): resp = client.get("/battery/9999/assign") assert resp.status_code == 404 def test_battery_assign_post_device_not_found(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/1/assign", data={"device_id": "9999"}, follow_redirects=True) assert resp.status_code == 200 assert b"not found" in resp.data.lower() def test_battery_assign_to_device_with_children_blocked(client): """Assigning to a parent device (has sub-components) is blocked.""" client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) client.post("/device/add", data={"name": "Child", "battery_slots": "2", "battery_size": "AA", "parent_id": "1"}) client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/1/assign", data={"device_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"sub-component" in resp.data.lower() # ------------------------------------------------------------------ # # battery_unassign — 404 (469) # ------------------------------------------------------------------ # def test_battery_unassign_404(client): resp = client.post("/battery/9999/unassign") assert resp.status_code == 404 # ------------------------------------------------------------------ # # battery_retire — 404 (484) # ------------------------------------------------------------------ # def test_battery_retire_404(client): resp = client.post("/battery/9999/retire") assert resp.status_code == 404 # ------------------------------------------------------------------ # # battery_unretire — 404 (502) # ------------------------------------------------------------------ # def test_battery_unretire_404(client): resp = client.post("/battery/9999/unretire") assert resp.status_code == 404 # ------------------------------------------------------------------ # # battery_delete — 404 GET (519) # ------------------------------------------------------------------ # def test_battery_delete_get_404(client): resp = client.get("/battery/9999/delete") assert resp.status_code == 404 # ------------------------------------------------------------------ # # bulk_action — set_brand no brand, install_device no device_id, # device not found, has_children, no eligible, brand mix, # already there + retired skipped notes (565-621) # ------------------------------------------------------------------ # def test_bulk_set_brand_empty_brand(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/bulk-action", data={"battery_ids": ["1"], "action": "set_brand", "new_brand": ""}, follow_redirects=True) assert resp.status_code == 200 assert b"required" in resp.data.lower() def test_bulk_install_device_no_device_id(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/bulk-action", data={"battery_ids": ["1"], "action": "install_device"}, follow_redirects=True) assert resp.status_code == 200 assert b"select a device" in resp.data.lower() def test_bulk_install_device_not_found(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/bulk-action", data={"battery_ids": ["1"], "action": "install_device", "device_id": "9999"}, follow_redirects=True) assert resp.status_code == 200 assert b"not found" in resp.data.lower() def test_bulk_install_device_has_children_blocked(client): client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) client.post("/device/add", data={"name": "Child", "battery_slots": "2", "battery_size": "AA", "parent_id": "1"}) client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/bulk-action", data={"battery_ids": ["1"], "action": "install_device", "device_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"sub-component" in resp.data.lower() def test_bulk_install_no_eligible_batteries(client): """All selected batteries are retired → no eligible batteries.""" client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "B", "count": "1"}) client.post("/battery/1/retire") resp = client.post("/battery/bulk-action", data={"battery_ids": ["1"], "action": "install_device", "device_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"no eligible" in resp.data.lower() def test_bulk_install_brand_mix_warning(client): """Bulk install produces brand-mix warning when mixing brands.""" client.post("/device/add", data={"name": "Box", "battery_slots": "4", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Alpha", "count": "1"}) client.post("/battery/add", data={"brand": "Beta", "count": "1"}) client.post("/battery/1/assign", data={"device_id": "1"}) # install Alpha first resp = client.post("/battery/bulk-action", data={"battery_ids": ["2"], "action": "install_device", "device_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"mixing" in resp.data.lower() or b"brand" in resp.data.lower() def test_bulk_install_already_there_note(client): """Battery already in target device → message notes 'already there'.""" client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "B", "count": "2"}) client.post("/battery/1/assign", data={"device_id": "1"}) # already in Box 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"already there" in resp.data.lower() def test_bulk_install_retired_skipped_note(client): """Retired battery in selection → 'retired skipped' note in message.""" client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "B", "count": "2"}) client.post("/battery/2/retire") 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"retired skipped" in resp.data.lower() # ------------------------------------------------------------------ # # bulk_action — set_field, log_charged, unknown (624-655) # ------------------------------------------------------------------ # def test_bulk_set_field_storage_location(client): client.post("/battery/add", data={"brand": "B", "count": "2"}) resp = client.post("/battery/bulk-action", data={"battery_ids": ["1", "2"], "action": "set_field", "field_name": "storage_location", "field_value": "Drawer"}, follow_redirects=True) assert resp.status_code == 200 assert b"Drawer" in client.get("/battery/1").data def test_bulk_set_field_invalid_field(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/bulk-action", data={"battery_ids": ["1"], "action": "set_field", "field_name": "status", "field_value": "retired"}, follow_redirects=True) assert resp.status_code == 200 assert b"invalid field" in resp.data.lower() def test_bulk_set_field_brand_empty(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/bulk-action", data={"battery_ids": ["1"], "action": "set_field", "field_name": "brand", "field_value": ""}, follow_redirects=True) assert resp.status_code == 200 assert b"required" in resp.data.lower() def test_bulk_log_charged(client): client.post("/battery/add", data={"brand": "B", "count": "2"}) resp = client.post("/battery/bulk-action", data={"battery_ids": ["1", "2"], "action": "log_charged", "charged_date": "2026-01-15"}, follow_redirects=True) assert resp.status_code == 200 assert b"logged charge" in resp.data.lower() def test_bulk_log_charged_with_cycles(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/bulk-action", data={"battery_ids": ["1"], "action": "log_charged", "charged_date": "2026-01-15", "increment_cycles": "on"}, follow_redirects=True) assert resp.status_code == 200 assert b"+cycles" in resp.data.lower() or b"cycles" in resp.data.lower() def test_bulk_log_charged_missing_date(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/bulk-action", data={"battery_ids": ["1"], "action": "log_charged", "charged_date": ""}, follow_redirects=True) assert resp.status_code == 200 assert b"valid date" in resp.data.lower() def test_bulk_unknown_action(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/bulk-action", data={"battery_ids": ["1"], "action": "bogus_action"}, follow_redirects=True) assert resp.status_code == 200 assert b"unknown action" in resp.data.lower() # ------------------------------------------------------------------ # # ha_entities — when HA not enabled (line 666) # ------------------------------------------------------------------ # def test_ha_entities_disabled(client): resp = client.get("/ha/entities") assert resp.status_code == 200 assert resp.json == [] # ------------------------------------------------------------------ # # device_add GET (line 782) # ------------------------------------------------------------------ # def test_device_add_get(client): resp = client.get("/device/add") assert resp.status_code == 200 def test_device_add_get_with_invalid_parent_id(client): """parent_id that is not a digit → prefill_parent remains None, no crash.""" resp = client.get("/device/add?parent_id=abc") assert resp.status_code == 200 # ------------------------------------------------------------------ # # device_add POST — parent validation (710-727, 743-746) # ------------------------------------------------------------------ # def test_device_add_parent_not_found(client): resp = client.post("/device/add", data={"name": "Child", "battery_slots": "2", "battery_size": "AA", "parent_id": "9999"}) assert resp.status_code == 400 assert b"not found" in resp.data.lower() def test_device_add_parent_is_subcomponent_blocked(client): """Cannot nest sub-components more than one level.""" client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) client.post("/device/add", data={"name": "Child", "battery_slots": "2", "battery_size": "AA", "parent_id": "1"}) resp = client.post("/device/add", data={"name": "GrandChild", "battery_slots": "1", "battery_size": "AA", "parent_id": "2"}) assert resp.status_code == 400 assert b"one level" in resp.data.lower() def test_device_add_parent_has_slots_blocked(client): """Parent must have battery_slots=0.""" client.post("/device/add", data={"name": "NotAParent", "battery_slots": "2", "battery_size": "AA"}) resp = client.post("/device/add", data={"name": "Sub", "battery_slots": "1", "battery_size": "AA", "parent_id": "1"}) assert resp.status_code == 400 def test_device_add_invalid_slots(client): resp = client.post("/device/add", data={"name": "Box", "battery_slots": "-1", "battery_size": "AA"}) assert resp.status_code == 400 assert b"non-negative" in resp.data.lower() def test_device_add_invalid_slots_text(client): resp = client.post("/device/add", data={"name": "Box", "battery_slots": "abc", "battery_size": "AA"}) assert resp.status_code == 400 # ------------------------------------------------------------------ # # device_edit — validation errors (858-910) # ------------------------------------------------------------------ # def test_device_edit_404(client): resp = client.post("/device/9999/edit", data={"name": "X", "battery_slots": "1"}) assert resp.status_code == 404 def test_device_edit_empty_name(client): client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) resp = client.post("/device/1/edit", data={"name": "", "battery_slots": "2"}, follow_redirects=True) assert resp.status_code == 200 assert b"required" in resp.data.lower() def test_device_edit_invalid_slots_negative(client): client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) resp = client.post("/device/1/edit", data={"name": "Box", "battery_slots": "-1"}, follow_redirects=True) assert resp.status_code == 200 assert b"positive" in resp.data.lower() def test_device_edit_zero_slots_without_children(client): """Setting slots to 0 on a device without children is invalid.""" client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) resp = client.post("/device/1/edit", data={"name": "Box", "battery_slots": "0"}, follow_redirects=True) assert resp.status_code == 200 assert b"positive" in resp.data.lower() def test_device_edit_duplicate_name(client): client.post("/device/add", data={"name": "Box A", "battery_slots": "2", "battery_size": "AA"}) client.post("/device/add", data={"name": "Box B", "battery_slots": "1", "battery_size": "AA"}) resp = client.post("/device/2/edit", data={"name": "Box A", "battery_slots": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"already exists" in resp.data.lower() def test_device_edit_parent_not_found(client): client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) resp = client.post("/device/1/edit", data={"name": "Box", "battery_slots": "2", "parent_id": "9999"}, follow_redirects=True) assert resp.status_code == 200 assert b"not found" in resp.data.lower() def test_device_edit_own_parent(client): client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) resp = client.post("/device/1/edit", data={"name": "Box", "battery_slots": "2", "parent_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"own parent" in resp.data.lower() def test_device_edit_cannot_make_parent_into_subcomponent(client): """A device that already has children cannot become a sub-component.""" client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) client.post("/device/add", data={"name": "Child", "battery_slots": "2", "battery_size": "AA", "parent_id": "1"}) client.post("/device/add", data={"name": "Other", "battery_slots": "0", "battery_size": ""}) resp = client.post("/device/1/edit", data={"name": "Parent", "battery_slots": "0", "parent_id": "3"}, follow_redirects=True) assert resp.status_code == 200 assert b"sub-component" in resp.data.lower() def test_device_edit_new_parent_is_subcomponent(client): """Cannot use a sub-component as a parent (no double nesting).""" client.post("/device/add", data={"name": "P", "battery_slots": "0", "battery_size": ""}) client.post("/device/add", data={"name": "C", "battery_slots": "2", "battery_size": "AA", "parent_id": "1"}) client.post("/device/add", data={"name": "X", "battery_slots": "2", "battery_size": "AA"}) resp = client.post("/device/3/edit", data={"name": "X", "battery_slots": "2", "parent_id": "2"}, follow_redirects=True) assert resp.status_code == 200 assert b"one level" in resp.data.lower() def test_device_edit_new_parent_has_slots(client): """New parent must have battery_slots=0.""" client.post("/device/add", data={"name": "NotParent", "battery_slots": "2", "battery_size": "AA"}) client.post("/device/add", data={"name": "Child", "battery_slots": "1", "battery_size": "AA"}) resp = client.post("/device/2/edit", data={"name": "Child", "battery_slots": "1", "parent_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"battery slots set to 0" in resp.data.lower() # ------------------------------------------------------------------ # # device_install — 404 and has_children and no pairs (946-967) # ------------------------------------------------------------------ # def test_device_install_404(client): resp = client.post("/device/9999/install", data={"brand[]": "B", "qty[]": "1"}) assert resp.status_code == 404 def test_device_install_has_children_blocked(client): client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) client.post("/device/add", data={"name": "Child", "battery_slots": "2", "battery_size": "AA", "parent_id": "1"}) resp = client.post("/device/1/install", data={"brand[]": "B", "qty[]": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"sub-component" in resp.data.lower() def test_device_install_no_pairs(client): """Submit form with empty brand/qty → 'No batteries specified'.""" client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) resp = client.post("/device/1/install", data={"brand[]": "", "qty[]": "0"}, follow_redirects=True) assert resp.status_code == 200 assert b"no batteries specified" in resp.data.lower() def test_device_install_invalid_qty_falls_to_zero(client): """Non-integer qty treated as 0 → no pairs → 'No batteries specified'.""" client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "B", "count": "2"}) resp = client.post("/device/1/install", data={"brand[]": "B", "qty[]": "abc"}, follow_redirects=True) assert resp.status_code == 200 assert b"no batteries specified" in resp.data.lower() # ------------------------------------------------------------------ # # device_install_one — 404, has_children, battery not found, # brand mix warning, size mismatch warning (1021-1045) # ------------------------------------------------------------------ # def test_device_install_one_404(client): resp = client.post("/device/9999/install-one", data={"battery_id": "1"}) assert resp.status_code == 404 def test_device_install_one_has_children_blocked(client): client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) client.post("/device/add", data={"name": "Child", "battery_slots": "2", "battery_size": "AA", "parent_id": "1"}) client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/device/1/install-one", data={"battery_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"sub-component" in resp.data.lower() def test_device_install_one_battery_not_found(client): client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) resp = client.post("/device/1/install-one", data={"battery_id": "9999"}, follow_redirects=True) assert resp.status_code == 200 assert b"not found" in resp.data.lower() or b"not available" in resp.data.lower() def test_device_install_one_brand_mix_warning(client): """Installing a different brand produces a warning flash.""" client.post("/device/add", data={"name": "Box", "battery_slots": "3", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "Alpha", "count": "1"}) client.post("/battery/add", data={"brand": "Beta", "count": "1"}) client.post("/device/1/install-one", data={"battery_id": "1"}) resp = client.post("/device/1/install-one", data={"battery_id": "2"}, follow_redirects=True) assert resp.status_code == 200 assert b"mixing" in resp.data.lower() or b"brand" in resp.data.lower() def test_device_install_one_size_mismatch_warning(client): """Installing wrong-size battery produces a warning flash.""" client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "B", "count": "1"}) client.post("/battery/1/edit-details", data={"size": "AAA"}) resp = client.post("/device/1/install-one", data={"battery_id": "1"}, follow_redirects=True) assert resp.status_code == 200 assert b"aaa" in resp.data.lower() or b"size" in resp.data.lower() # ------------------------------------------------------------------ # # device_delete — 404 (1064) # ------------------------------------------------------------------ # def test_device_delete_404(client): resp = client.post("/device/9999/delete") assert resp.status_code == 404 def test_device_delete_with_children_detaches_them(client): """Deleting a parent frees sub-components as independent devices.""" client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) client.post("/device/add", data={"name": "Child", "battery_slots": "2", "battery_size": "AA", "parent_id": "1"}) resp = client.post("/device/1/delete", follow_redirects=True) assert resp.status_code == 200 assert b"independent" in resp.data.lower() # ------------------------------------------------------------------ # # device_unassign_all — 404 (1088) # ------------------------------------------------------------------ # def test_device_unassign_all_404(client): resp = client.post("/device/9999/unassign-all") assert resp.status_code == 404 # ------------------------------------------------------------------ # # device_install_batch — 404, has_children, no batteries, # overflow, none installed (1110-1136) # ------------------------------------------------------------------ # def test_device_install_batch_404(client): resp = client.post("/device/9999/install-batch", data={"battery_ids": ["1"]}) assert resp.status_code == 404 def test_device_install_batch_has_children_blocked(client): client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) client.post("/device/add", data={"name": "Child", "battery_slots": "2", "battery_size": "AA", "parent_id": "1"}) client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/device/1/install-batch", data={"battery_ids": ["1"]}, follow_redirects=True) assert resp.status_code == 200 assert b"sub-component" in resp.data.lower() def test_device_install_batch_no_batteries_selected(client): client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) resp = client.post("/device/1/install-batch", data={}, follow_redirects=True) assert resp.status_code == 200 assert b"no batteries selected" in resp.data.lower() def test_device_install_batch_none_installed_warning(client): """Selecting an already-installed battery → none actually installed.""" 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": "B", "count": "1"}) client.post("/battery/1/assign", data={"device_id": "1"}) resp = client.post("/device/2/install-batch", data={"battery_ids": ["1"]}, follow_redirects=True) assert resp.status_code == 200 assert b"no batteries were installed" in resp.data.lower() def test_device_install_batch_respects_free_slots(client): """Batch stops once free slots are exhausted (line 1122).""" client.post("/device/add", data={"name": "Box", "battery_slots": "1", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "B", "count": "3"}) resp = client.post("/device/1/install-batch", data={"battery_ids": ["1", "2", "3"]}, follow_redirects=True) assert resp.status_code == 200 assert b"1 / 1" in resp.data # ------------------------------------------------------------------ # # device_charge_all — 404 and missing date (1147-1151) # ------------------------------------------------------------------ # def test_device_charge_all_404(client): resp = client.post("/device/9999/charge-all", data={"charged_date": "2026-01-01"}) assert resp.status_code == 404 def test_device_charge_all_missing_date(client): client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) client.post("/battery/add", data={"brand": "B", "count": "1"}) client.post("/battery/1/assign", data={"device_id": "1"}) resp = client.post("/device/1/charge-all", data={"charged_date": ""}, follow_redirects=True) assert resp.status_code == 200 assert b"valid date" in resp.data.lower() def test_device_charge_all_no_installed_batteries(client): """charge-all on a device with no installed batteries → warning.""" client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) resp = client.post("/device/1/charge-all", data={"charged_date": "2026-01-01"}, follow_redirects=True) assert resp.status_code == 200 assert b"no installed batteries" in resp.data.lower() # ------------------------------------------------------------------ # # CSV exports — charge_logs, capacity_tests, pct_logs (1210, 1220, 1230) # ------------------------------------------------------------------ # def test_export_charge_logs_csv(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) client.post("/battery/1/charge-log/add", data={"charged_date": "2026-01-01"}) resp = client.get("/export/charge-logs.csv") assert resp.status_code == 200 assert b"charged_date" in resp.data assert b"2026-01-01" in resp.data def test_export_capacity_tests_csv(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) client.post("/battery/1/capacity-test/add", data={"tested_capacity_mah": "1800", "tested_date": "2026-01-01"}) resp = client.get("/export/capacity-tests.csv") assert resp.status_code == 200 assert b"tested_capacity_mah" in resp.data assert b"1800" in resp.data def test_export_pct_logs_csv(client): client.post("/battery/add", data={"brand": "B", "count": "1"}) client.post("/battery/1/edit-details", data={"battery_percentage": "75"}) resp = client.get("/export/pct-logs.csv") assert resp.status_code == 200 assert b"percentage" in resp.data assert b"75" in resp.data # ------------------------------------------------------------------ # # Import edge cases (1347-1511) # ------------------------------------------------------------------ # def test_import_file_too_large(client): big = b"x" * (10 * 1024 * 1024 + 1) resp = _post_import(client, big) assert resp.status_code == 400 assert b"too large" in resp.data.lower() def test_import_missing_required_keys(client): payload = json.dumps({"exported_at": "2026-01-01"}).encode() resp = _post_import(client, payload) assert resp.status_code == 400 def test_import_battery_empty_label_skipped(client): payload = _make_json(batteries=[ {"id": 1, "label": "", "brand": "B", "status": "available", "device_id": None, "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}, ]) resp = _post_import(client, payload) assert resp.status_code == 200 assert client.get("/battery/1").status_code == 404 def test_import_capacity_test_unresolved_battery_skipped(client): """capacity_test referencing a battery not in the payload is skipped.""" payload = _make_json( batteries=[], capacity_tests=[{"id": 1, "battery_id": 999, "battery_label": "X", "tested_capacity_mah": 1800, "tested_date": "2026-01-01", "notes": None}], ) resp = _post_import(client, payload) assert resp.status_code == 200 def test_import_pct_log_unresolved_battery_skipped(client): """pct_log referencing a battery not in the payload is skipped.""" payload = _make_json( batteries=[], pct_logs=[{"id": 1, "battery_id": 999, "battery_label": "X", "percentage": 80, "recorded_at": "2026-01-01 00:00:00", "source": "manual"}], ) resp = _post_import(client, payload) assert resp.status_code == 200 def test_import_pct_logs_appended(client): """pct_logs for an imported battery are appended.""" payload = _make_json( batteries=[{"id": 5, "label": "Foo 001", "brand": "Foo", "status": "available", "device_id": None, "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}], pct_logs=[{"id": 1, "battery_id": 5, "battery_label": "Foo 001", "percentage": 80, "recorded_at": "2026-01-01 00:00:00", "source": "manual"}], ) resp = _post_import(client, payload) assert resp.status_code == 200 csv_resp = client.get("/export/pct-logs.csv") assert b"80" in csv_resp.data def test_import_device_with_subcomponent(client): """Import correctly links sub-components to their parent's new ID.""" payload = _make_json( devices=[ {"id": 10, "name": "Hub", "battery_slots": 0, "battery_size": None, "device_type": None, "location": None, "ha_entity_id": None, "notes": None, "parent_id": None}, {"id": 11, "name": "Module", "battery_slots": 2, "battery_size": "AA", "device_type": None, "location": None, "ha_entity_id": None, "notes": None, "parent_id": 10}, ], ) resp = _post_import(client, payload) assert resp.status_code == 200 device_list = client.get("/device/") assert b"Hub" in device_list.data # ------------------------------------------------------------------ # # Logbook — 404 paths (1539, 1555, 1567, 1583) # ------------------------------------------------------------------ # def test_battery_logbook_add_404(client): resp = client.post("/battery/9999/logbook/add", data={"body": "test"}) assert resp.status_code == 404 def test_battery_logbook_delete_404(client): resp = client.post("/battery/9999/logbook/1/delete") assert resp.status_code == 404 def test_device_logbook_add_404(client): resp = client.post("/device/9999/logbook/add", data={"body": "test"}) assert resp.status_code == 404 def test_device_logbook_delete_404(client): resp = client.post("/device/9999/logbook/1/delete") assert resp.status_code == 404 def test_battery_logbook_add_empty_body(client): """Empty logbook body is rejected with flash error (no 404).""" client.post("/battery/add", data={"brand": "B", "count": "1"}) resp = client.post("/battery/1/logbook/add", data={"body": ""}, follow_redirects=True) assert resp.status_code == 200 assert b"required" in resp.data.lower() def test_device_logbook_add_empty_body(client): client.post("/device/add", data={"name": "Box", "battery_slots": "1", "battery_size": "AA"}) resp = client.post("/device/1/logbook/add", data={"body": ""}, follow_redirects=True) assert resp.status_code == 200 assert b"required" in resp.data.lower() def test_battery_logbook_delete_wrong_battery(client): """Logbook entry not belonging to the battery is silently ignored (no 404).""" client.post("/battery/add", data={"brand": "B", "count": "2"}) client.post("/battery/1/logbook/add", data={"body": "Entry"}) resp = client.post("/battery/2/logbook/1/delete", follow_redirects=True) assert resp.status_code == 200 def test_device_logbook_delete_wrong_device(client): client.post("/device/add", data={"name": "A", "battery_slots": "1", "battery_size": "AA"}) client.post("/device/add", data={"name": "B", "battery_slots": "1", "battery_size": "AA"}) client.post("/device/1/logbook/add", data={"body": "Entry"}) resp = client.post("/device/2/logbook/1/delete", follow_redirects=True) assert resp.status_code == 200 # ------------------------------------------------------------------ # # Model __repr__ methods (models.py lines 100, 157, 172, 187, 202, 217) # ------------------------------------------------------------------ # def test_model_reprs(app): from models import Battery, Device, CapacityTest, ChargeLog, BatteryPctLog, Logbook d = Device(name="TestDevice", battery_slots=2) assert "TestDevice" in repr(d) b = Battery(label="Test 001", brand="B", status="available") assert "Test 001" in repr(b) ct = CapacityTest(battery_id=1, tested_capacity_mah=1800, tested_date="2026-01-01") assert "1800" in repr(ct) cl = ChargeLog(battery_id=1, charged_date="2026-01-01", increment_cycles=0) assert "2026-01-01" in repr(cl) pl = BatteryPctLog(battery_id=1, percentage=80, recorded_at="2026-01-01 00:00:00") assert "80" in repr(pl) lb = Logbook(body="note", recorded_at="2026-01-01 00:00:00") assert "2026-01-01" in repr(lb) # ------------------------------------------------------------------ # # ha_client — disabled path and error path (lines 49, 67-69) # ------------------------------------------------------------------ # def test_ha_client_list_battery_entities_disabled(): from ha_client import HomeAssistantClient c = HomeAssistantClient(url=None, api_key=None) assert c.list_battery_entities() == [] def test_ha_client_list_battery_entities_error(): from ha_client import HomeAssistantClient import requests as req_lib c = HomeAssistantClient(url="http://fake", api_key="key") with patch("ha_client.requests.get", side_effect=req_lib.ConnectionError("boom")): result = c.list_battery_entities() assert result == [] # ------------------------------------------------------------------ # # ha_poller — stop() and exception path (lines 32, 64-66) # ------------------------------------------------------------------ # def test_ha_poller_stop(): from ha_poller import HaPoller from ha_client import HomeAssistantClient client = HomeAssistantClient(url=None, api_key=None) poller = HaPoller(client, MagicMock(), interval=300) poller.stop() assert poller._stop.is_set() def test_ha_poller_poll_once_exception_path(app): """_poll_once rolls back and logs on DB exception without crashing.""" from ha_poller import HaPoller from ha_client import HomeAssistantClient ha = HomeAssistantClient(url="http://fake", api_key="key") broken_session = MagicMock() broken_session.query.side_effect = RuntimeError("db error") poller = HaPoller(ha, lambda: broken_session, interval=300) poller._poll_once() broken_session.rollback.assert_called_once() broken_session.close.assert_called_once() # ------------------------------------------------------------------ # # battery_add GET — non-numeric label (line 216) # ------------------------------------------------------------------ # def test_battery_add_get_non_numeric_label_skipped(client): """battery_add GET skips prefix numbering for labels without trailing digits.""" payload = _make_json(batteries=[ {"id": 1, "label": "Custom", "brand": "B", "status": "available", "device_id": None, "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}, ]) _post_import(client, payload) resp = client.get("/battery/add") assert resp.status_code == 200 assert b"prefixMaxNums" in resp.data # ------------------------------------------------------------------ # # import — device with empty name is skipped (lines 1377-1378) # ------------------------------------------------------------------ # def test_import_device_empty_name_skipped(client): """Import skips devices whose name is empty.""" payload = _make_json(devices=[ {"id": 1, "name": "", "battery_slots": 2, "battery_size": "AA", "device_type": None, "location": None, "ha_entity_id": None, "notes": None, "parent_id": None}, ]) resp = _post_import(client, payload) assert resp.status_code == 200 assert client.get("/device/9999").status_code == 404 # ------------------------------------------------------------------ # # import — exception handler (lines 1508-1511) # ------------------------------------------------------------------ # def test_import_exception_handler(client): """Import with malformed batteries list triggers the 500 exception handler.""" payload = json.dumps({ "devices": [], "batteries": "not-a-list", }).encode() resp = _post_import(client, payload) assert resp.status_code == 500 assert b"Import failed" in resp.data # ------------------------------------------------------------------ # # ha_poller _run loop (line 37) # ------------------------------------------------------------------ # def test_ha_poller_run_loop_executes_poll(app): """_run loop calls _poll_once at least once before being stopped.""" from ha_poller import HaPoller from ha_client import HomeAssistantClient ha = HomeAssistantClient(url=None, api_key=None) call_count = [0] poller = HaPoller(ha, MagicMock(), interval=0.01) def fake_poll(): call_count[0] += 1 poller.stop() poller._poll_once = fake_poll poller._run() assert call_count[0] >= 1 # ------------------------------------------------------------------ # # can_have_subcomponents model method # ------------------------------------------------------------------ # def test_can_have_subcomponents_true(app): from models import Device d = Device(name="Parent", battery_slots=0, parent_id=None, parent_key=-1) assert d.can_have_subcomponents() is True def test_can_have_subcomponents_false_has_slots(app): from models import Device d = Device(name="Regular", battery_slots=2, parent_id=None, parent_key=-1) assert d.can_have_subcomponents() is False def test_can_have_subcomponents_false_is_subcomponent(app): from models import Device d = Device(name="Child", battery_slots=0, parent_id=1, parent_key=1) assert d.can_have_subcomponents() is False