1229 lines
50 KiB
Python
1229 lines
50 KiB
Python
"""
|
|
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
|