Files
shopdb-flask/tests/test_plugins/test_supply_history.py
cproudlock 7d66551622
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s
Forecast from the right end of the window, and from the level shown
Four defects stacked into one nonsense report: cartridges at 20% claiming four
days, cartridges at 1% claiming weeks.

The root cause is a Zabbix API detail. `limit` caps the whole result set rather
than each item, and the query sorted ascending, so the cap kept the OLDEST rows
in the window. A four-cartridge printer polled every five minutes writes over
100k readings in 90 days; the forecast was fitted to the first few days of that
and nothing since. Every rate was real and every rate described a cartridge
thrown away three months ago. Nothing in the output looks wrong, which is why
it needed pinning in a test rather than a comment.

A 90-day burn rate does not need every individual poll, so a long window now
reads hourly trends - the table meant for this, a tenth of the rows, and kept
longer. Raw history serves short windows and any item a site keeps no trends
for. Both are fetched newest-first with the budget scaled per item.

Second, the countdown was computed from the last stored reading while the level
displayed was the live one, so the two could disagree by a whole cartridge. The
live level is now what the countdown divides. A live level far above the stored
run means it was swapped since the last reading, and that is reported as a
replacement rather than as a collapse in the burn rate.

Third, at or below 5% a cartridge reads as empty rather than as a slow drain.
At 1% losing a tenth of a point a day the arithmetic says ten days. The printer
is out of toner, and it is the first thing to order.

Fourth, the days-left column spanned the printer's rows, so the printer's
soonest figure was printed beside every supply it had. That alone accounts for
the shape of both complaints: a healthy cartridge wearing its neighbour's
deadline, and an empty one wearing a number that belonged to nothing on its row.

Also fixes float-typed supplies vanishing from any printer that also had an
integer-typed one - they live in different history tables and the fetch stopped
at whichever answered first.

Not verified against live data: Zabbix is not reachable from the dev box.
2026-08-13 10:54:23 -04:00

183 lines
6.0 KiB
Python

"""Tests for toner burn-rate and replacement counting.
The analysis is pure arithmetic over a list of readings, so it is tested that
way - no Zabbix, no fixtures. What matters is that it refuses to guess: the
cases where an estimate would be dishonest are the ones most likely to reach a
purchasing decision.
"""
from datetime import datetime, timedelta, timezone
from plugins.printers.services.supply_history import (
analyse, burn_rate, current_run, find_replacements, normalise, soonest,
)
START = datetime(2026, 6, 1, tzinfo=timezone.utc)
def series(levels, hours=24):
"""[(clock, value)] one reading per `hours`, as Zabbix returns them."""
return [(str(int((START + timedelta(hours=i * hours)).timestamp())), str(v))
for i, v in enumerate(levels)]
def test_counts_one_replacement_per_upward_step():
points = normalise(series([80, 60, 40, 20, 100, 80, 60, 95, 70]))
assert len(find_replacements(points)) == 2
def test_ignores_a_wobble_that_is_not_a_replacement():
"""SNMP rounding and a gauge settling both nudge a reading upward."""
points = normalise(series([60, 58, 61, 57, 55]))
assert find_replacements(points) == []
def test_estimate_uses_only_the_current_cartridge():
"""Fitting across a swap averages a spent cartridge with a fresh one."""
points = normalise(series([90, 60, 30, 100, 90, 80, 70]))
run = current_run(points)
assert [level for _, level in run] == [100, 90, 80, 70]
def test_days_left_from_a_steady_drain():
# 10 points a day apart, 5% a day, ending at 55%
result = analyse(series([100, 95, 90, 85, 80, 75, 70, 65, 60, 55]))
assert result['burnrateperday'] == 5.0
assert result['daysleft'] == 11 # 55 / 5
assert result['reason'] is None
assert result['basisdays'] == 9.0
def test_no_estimate_from_two_readings():
"""Two points through a coarse gauge can prove any rate at all."""
result = analyse(series([100, 90]))
assert result['daysleft'] is None
assert result['reason'] == 'not enough history yet'
def test_no_estimate_while_the_gauge_has_not_moved():
"""A printer reporting in 10% steps sits on a plateau for a fortnight."""
result = analyse(series([70, 70, 70, 70, 70, 70]))
assert result['daysleft'] is None
assert result['reason'] == 'level has not moved enough to estimate'
def test_a_recent_replacement_says_so_rather_than_guessing():
result = analyse(series([40, 30, 20, 10, 100, 98]))
assert result['daysleft'] is None
assert result['reason'] == 'replaced recently'
assert result['replacements'] == 1
assert result['lastreplaced'] is not None
def test_empty_history_is_reported_not_crashed():
result = analyse([])
assert result['reason'] == 'no history'
assert result['currentlevel'] is None
assert result['replacements'] == 0
def test_junk_rows_are_dropped_not_fatal():
points = normalise([('notaclock', '50'), ('1780000000', 'n/a'),
('1780000000', '50')])
assert len(points) == 1
def test_days_left_never_goes_negative():
result = analyse(series([20, 15, 10, 5, 0]))
assert result['daysleft'] == 0
def test_live_level_drives_the_countdown_not_the_stored_one():
"""The report shows the live level, so it must count down from that one.
History lags a poll, and trends lag an hour. Displaying 20% beside a
countdown computed from a stored 4% is how a report loses its reader.
"""
result = analyse(series([100, 80, 60, 40]), currentlevel=20)
assert result['currentlevel'] == 20
assert result['burnrateperday'] == 20.0 # 60 points over 3 days
assert result['daysleft'] == 1 # 20 / 20, not 40 / 20
def test_a_nearly_empty_cartridge_reads_as_empty_not_as_weeks_away():
"""1% draining a tenth of a point a day computes to ten days. It is out."""
result = analyse(series([2.0, 1.7, 1.4, 1.1]), currentlevel=1)
assert result['daysleft'] == 0
def test_empty_sorts_ahead_of_a_fast_healthy_cartridge():
empty = analyse(series([2.0, 1.7, 1.4, 1.1]), currentlevel=1)
healthy = analyse(series([100, 80, 60, 40]))
assert empty['daysleft'] < healthy['daysleft']
def test_a_low_live_level_is_actionable_without_any_history():
"""A printer new to Zabbix still reports it is out of toner."""
result = analyse([], currentlevel=1)
assert result['daysleft'] == 0
assert result['reason'] is None
def test_no_history_and_a_healthy_level_still_says_no_history():
result = analyse([], currentlevel=80)
assert result['daysleft'] is None
assert result['reason'] == 'no history'
def test_a_live_level_above_the_stored_run_means_it_was_just_swapped():
"""Trends lag an hour; a cartridge changed in that hour must not inherit
the rate of the one that came out."""
result = analyse(series([40, 30, 20, 10]), currentlevel=100)
assert result['daysleft'] is None
assert result['reason'] == 'replaced recently'
assert result['replacements'] == 1
def test_printer_sorts_by_its_soonest_supply():
"""A colour MFP is as urgent as its most pressing cartridge."""
supplies = [{'daysleft': 40}, {'daysleft': 6}, {'daysleft': None}]
assert soonest(supplies) == 6
def test_soonest_is_none_when_nothing_can_be_estimated():
assert soonest([{'daysleft': None}, {'daysleft': None}]) is None
def test_burn_rate_needs_elapsed_time():
"""Several readings in the same second is not a rate."""
clock = str(int(START.timestamp()))
points = normalise([(clock, '90'), (clock, '80'), (clock, '70'), (clock, '60')])
assert burn_rate(points) is None
def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db):
"""A dashboard must be told the data is missing, not shown an empty list
that reads as 'nothing runs out soon'."""
response = client.get('/api/printers/supplies/forecast')
assert response.status_code == 200
body = response.get_json()['data']
assert body['available'] is False
assert 'not configured' in body['reason']
assert body['printers'] == []